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

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

MartinNYHC 1 месяц назад
Родитель
Сommit
ea1869aeb3
41 измененных файлов с 2583 добавлено и 200 удалено
  1. 1 0
      BACKERS.md
  2. 4 0
      CHANGELOG.md
  3. 116 8
      backend/app/api/routes/archives.py
  4. 7 0
      backend/app/api/routes/camera.py
  5. 12 0
      backend/app/core/database.py
  6. 34 1
      backend/app/core/logging_filters.py
  7. 381 132
      backend/app/main.py
  8. 10 0
      backend/app/models/archive.py
  9. 148 4
      backend/app/services/bambu_ftp.py
  10. 253 4
      backend/app/services/bambu_mqtt.py
  11. 4 1
      backend/app/services/camera.py
  12. 18 8
      backend/app/services/external_camera.py
  13. 6 2
      backend/app/services/log_reader.py
  14. 34 2
      backend/app/services/printer_diagnostic.py
  15. 5 0
      backend/app/services/printer_manager.py
  16. 6 0
      backend/app/services/virtual_printer/mqtt_bridge.py
  17. 9 2
      backend/tests/integration/test_timelapse_scan_session.py
  18. 324 0
      backend/tests/unit/services/test_bambu_mqtt.py
  19. 57 1
      backend/tests/unit/services/test_printer_diagnostic.py
  20. 66 0
      backend/tests/unit/test_a2l_ams_lite_2619.py
  21. 68 17
      backend/tests/unit/test_archive_filtering.py
  22. 11 4
      backend/tests/unit/test_finish_photo_from_timelapse.py
  23. 112 0
      backend/tests/unit/test_log_credential_redaction.py
  24. 767 0
      backend/tests/unit/test_timelapse_scan_2704.py
  25. 71 0
      backend/tests/unit/test_vp_mqtt_bridge.py
  26. 32 0
      frontend/src/__tests__/components/ConnectionDiagnosticModal.test.tsx
  27. 2 1
      frontend/src/i18n/locales/de.ts
  28. 2 1
      frontend/src/i18n/locales/en.ts
  29. 2 1
      frontend/src/i18n/locales/es.ts
  30. 2 1
      frontend/src/i18n/locales/fr.ts
  31. 2 1
      frontend/src/i18n/locales/it.ts
  32. 2 1
      frontend/src/i18n/locales/ja.ts
  33. 2 1
      frontend/src/i18n/locales/ko.ts
  34. 2 1
      frontend/src/i18n/locales/pt-BR.ts
  35. 2 1
      frontend/src/i18n/locales/ru.ts
  36. 2 1
      frontend/src/i18n/locales/tr.ts
  37. 2 1
      frontend/src/i18n/locales/uk.ts
  38. 2 1
      frontend/src/i18n/locales/zh-CN.ts
  39. 2 1
      frontend/src/i18n/locales/zh-TW.ts
  40. 0 0
      static/assets/index-xPJs-OAQ.js
  41. 1 1
      static/index.html

+ 1 - 0
BACKERS.md

@@ -37,6 +37,7 @@ If you sponsor and your name isn't here within 48h, please write an email to mar
 - [@MethodicalMartian](https://github.com/MethodicalMartian)
 - [@brianharwell](https://github.com/brianharwell)
 - [@shosier01](https://github.com/shosier01)
+- [@freifunk-bamberg](https://github.com/freifunk-bamberg)
 
 ## Backers ($5/mo+)
 

Разница между файлами не показана из-за своего большого размера
+ 4 - 0
CHANGELOG.md


+ 116 - 8
backend/app/api/routes/archives.py

@@ -123,6 +123,28 @@ def _match_timelapse_by_timestamp(
     return best_video, best_diff
 
 
+async def _claimed_timelapse_stems(db, printer_id: int | None, exclude_archive_id: int) -> set[str]:
+    """Video filenames already attached to another archive of this printer (#2704).
+
+    Lets the baseline diff drop a previous print's late-landing video from the
+    candidate list without ordering the candidates — ordering could only be done
+    on mtime or the filename timestamp, and both come from a clock the printer
+    can't sync in LAN-only mode. ``attach_timelapse`` stores the video under the
+    printer's own filename and the MP4 conversion keeps the stem, so the stem of
+    ``timelapse_path`` is what was claimed.
+    """
+    if printer_id is None:
+        return set()
+    rows = await db.execute(
+        select(PrintArchive.timelapse_path).where(
+            PrintArchive.printer_id == printer_id,
+            PrintArchive.id != exclude_archive_id,
+            PrintArchive.timelapse_path.is_not(None),
+        )
+    )
+    return {Path(p).stem for p in rows.scalars().all() if p}
+
+
 def _ensure_archive_visible(
     archive: PrintArchive | None,
     user: User | None,
@@ -2270,9 +2292,11 @@ async def scan_timelapse(
     from backend.app.core.database import async_session
     from backend.app.models.printer import Printer
     from backend.app.services.bambu_ftp import (
+        delete_archived_timelapse,
         download_file_bytes_async,
         get_ftp_retry_settings,
         list_files_async,
+        remote_file_settled,
         with_ftp_retry,
     )
 
@@ -2322,18 +2346,48 @@ async def scan_timelapse(
         f for f in files if not f.get("is_directory") and f.get("name", "").lower().endswith((".mp4", ".avi"))
     ]
 
+    # Strategy 0: snapshot diff against the baseline captured at print start
+    # (#2704). This is the same comparison the automatic scan makes, and the
+    # only one here that doesn't depend on the printer's clock — a printer in
+    # LAN-only mode can't reach Bambu's NTP server, so the timestamps in both
+    # the filename and the FTP mtime can be days out. One reporter's P1S was
+    # six and a half days off, which defeats every strategy below.
+    #
+    # When a baseline exists it is authoritative and the clock-based strategies
+    # are skipped entirely: they can only turn an honest "pick one yourself"
+    # into a confident wrong answer. Those strategies stay for archives created
+    # before the baseline was persisted.
+    used_baseline = archive.timelapse_baseline is not None
+    if used_baseline:
+        baseline = set(archive.timelapse_baseline)
+        async with async_session() as db:
+            claimed = await _claimed_timelapse_stems(db, archive.printer_id, archive_id)
+        candidates = [
+            f for f in video_files if f.get("name", "") not in baseline and Path(f.get("name", "")).stem not in claimed
+        ]
+        if len(candidates) == 1:
+            matching_file = candidates[0]
+            logger.info("Matched timelapse by print-start baseline: %s", matching_file.get("name"))
+        elif candidates:
+            # Ambiguous — offer only the plausible files instead of guessing.
+            video_files = candidates
+            logger.info("Baseline left %s unclaimed candidates for archive %s", len(candidates), archive_id)
+        else:
+            logger.info("Baseline shows no unclaimed new video on the printer for archive %s", archive_id)
+
     # Strategy 1: Match by print name in filename
-    for f in video_files:
-        fname = f.get("name", "")
-        if base_name.lower() in fname.lower():
-            matching_file = f
-            break
+    if not used_baseline:
+        for f in video_files:
+            fname = f.get("name", "")
+            if base_name.lower() in fname.lower():
+                matching_file = f
+                break
 
     # Strategy 2: Match by timestamp proximity against print START time.
     # Bambu timelapse filename embeds the print start time in printer-local clock.
     # See _match_timelapse_by_timestamp for the offset-search rationale and why we
     # intentionally don't try to match filename against end time here.
-    if not matching_file and archive.started_at:
+    if not used_baseline and not matching_file and archive.started_at:
         candidate, diff = _match_timelapse_by_timestamp(video_files, archive.started_at)
         if candidate is not None:
             matching_file = candidate
@@ -2341,7 +2395,7 @@ async def scan_timelapse(
 
     # Strategy 3: Use file modification time from FTP listing
     # This handles cases where printer's filename timestamp is wrong but file mtime is correct
-    if not matching_file and (archive.started_at or archive.completed_at or archive.created_at):
+    if not used_baseline and not matching_file and (archive.started_at or archive.completed_at or archive.created_at):
         from datetime import datetime, timedelta
 
         _archive_start = archive.started_at
@@ -2369,7 +2423,7 @@ async def scan_timelapse(
 
     # Strategy 4: If only one timelapse exists and archive was recently completed, use it
     # This handles cases where printer clock is wrong or timezone issues exist
-    if not matching_file and len(video_files) == 1:
+    if not used_baseline and not matching_file and len(video_files) == 1:
         from datetime import datetime, timedelta, timezone
 
         archive_completed = archive.completed_at or archive.created_at
@@ -2419,6 +2473,7 @@ async def scan_timelapse(
             remote_path,
             socket_timeout=ftp_timeout,
             printer_model=printer.model,
+            expected_size=matching_file.get("size"),
             max_retries=ftp_retry_count,
             retry_delay=ftp_retry_delay,
             operation_name=f"Download timelapse {matching_file['name']}",
@@ -2430,11 +2485,24 @@ async def scan_timelapse(
             remote_path,
             socket_timeout=ftp_timeout,
             printer_model=printer.model,
+            expected_size=matching_file.get("size"),
         )
 
     if not timelapse_data:
         raise HTTPException(500, "Failed to download timelapse")
 
+    # Confirm the printer has finished writing before we commit to this file and
+    # delete the original: matching the listing's size proves we got what it
+    # said, not that the file was complete (#2704).
+    if not await remote_file_settled(
+        printer.ip_address,
+        printer.access_code,
+        remote_path,
+        len(timelapse_data),
+        printer_model=printer.model,
+    ):
+        raise HTTPException(409, "The printer is still writing this video — try again in a moment")
+
     # Attach in a fresh short session (the read session was released before FTP).
     async with async_session() as db:
         success = await ArchiveService(db).attach_timelapse(archive_id, timelapse_data, matching_file["name"])
@@ -2442,6 +2510,17 @@ async def scan_timelapse(
     if not success:
         raise HTTPException(500, "Failed to attach timelapse")
 
+    # Safe now, and only now: the transfer matched the size the listing reported
+    # and the bytes are committed to the archive (#2704).
+    await delete_archived_timelapse(
+        printer.ip_address,
+        printer.access_code,
+        remote_path,
+        verified=matching_file.get("size") is not None,
+        printer_model=printer.model,
+        printer_name=printer.name,
+    )
+
     return {
         "status": "attached",
         "message": f"Timelapse '{matching_file['name']}' attached successfully",
@@ -2459,9 +2538,11 @@ async def select_timelapse(
     from backend.app.core.database import async_session
     from backend.app.models.printer import Printer
     from backend.app.services.bambu_ftp import (
+        delete_archived_timelapse,
         download_file_bytes_async,
         get_ftp_retry_settings,
         list_files_async,
+        remote_file_settled,
         with_ftp_retry,
     )
 
@@ -2484,6 +2565,7 @@ async def select_timelapse(
     # Find the file on the printer
     files = []
     remote_path = None
+    expected_size = None
     for timelapse_dir in ["/timelapse", "/timelapse/video", "/record", "/recording"]:
         try:
             files = await list_files_async(
@@ -2492,6 +2574,7 @@ async def select_timelapse(
             for f in files:
                 if f.get("name") == filename:
                     remote_path = f.get("path") or f"{timelapse_dir}/{filename}"
+                    expected_size = f.get("size")
                     break
             if remote_path:
                 break
@@ -2512,6 +2595,7 @@ async def select_timelapse(
             remote_path,
             socket_timeout=ftp_timeout,
             printer_model=printer.model,
+            expected_size=expected_size,
             max_retries=ftp_retry_count,
             retry_delay=ftp_retry_delay,
             operation_name=f"Download timelapse {filename}",
@@ -2523,17 +2607,41 @@ async def select_timelapse(
             remote_path,
             socket_timeout=ftp_timeout,
             printer_model=printer.model,
+            expected_size=expected_size,
         )
 
     if not timelapse_data:
         raise HTTPException(500, "Failed to download timelapse")
 
+    # Confirm the printer has finished writing before we commit to this file and
+    # delete the original: matching the listing's size proves we got what it
+    # said, not that the file was complete (#2704).
+    if not await remote_file_settled(
+        printer.ip_address,
+        printer.access_code,
+        remote_path,
+        len(timelapse_data),
+        printer_model=printer.model,
+    ):
+        raise HTTPException(409, "The printer is still writing this video — try again in a moment")
+
     # Attach in a fresh short session (the read session was released before FTP).
     async with async_session() as db:
         success = await ArchiveService(db).attach_timelapse(archive_id, timelapse_data, filename)
     if not success:
         raise HTTPException(500, "Failed to attach timelapse")
 
+    # Safe now, and only now: the transfer matched the size the listing reported
+    # and the bytes are committed to the archive (#2704).
+    await delete_archived_timelapse(
+        printer.ip_address,
+        printer.access_code,
+        remote_path,
+        verified=expected_size is not None,
+        printer_model=printer.model,
+        printer_name=printer.name,
+    )
+
     return {
         "status": "attached",
         "message": f"Timelapse '{filename}' attached successfully",

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

@@ -19,6 +19,7 @@ from backend.app.core.auth import (
     create_camera_stream_token,
 )
 from backend.app.core.database import get_db
+from backend.app.core.logging_filters import redact_url_credentials
 from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
 from backend.app.models.user import User
@@ -276,9 +277,15 @@ def _summarize_ffmpeg_stderr(text: str | None) -> str:
     any actual error message. Logging the full banner on every retry floods
     the log (hundreds of lines per failed stream). This filter drops the
     banner and caps output at the last 10 meaningful lines.
+
+    Credentials are masked here rather than at each ``logger`` call because
+    this is the one funnel every stderr log in this module passes through.
+    ffmpeg echoes the RTSP input URL back in its ``Input #0`` line, which
+    carries the printer access code.
     """
     if not text:
         return ""
+    text = redact_url_credentials(text) or ""
     banner_prefixes = (
         "ffmpeg version ",
         "  built with ",

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

@@ -3806,6 +3806,18 @@ async def run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN library_file_id INTEGER")
     await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_sets INTEGER")
 
+    # Migration: persist the timelapse snapshot-diff baseline (#2704).
+    # The list of video filenames present on the printer when the print began,
+    # so the diff survives a restart and the manual scan can use it instead of
+    # the clock-based matching that a LAN-only printer defeats. No dialect
+    # branch: SQLAlchemy renders this column as `JSON` on both SQLite and
+    # Postgres for a fresh install (checked with CreateTable against each
+    # dialect), so spelling the ALTER the same way keeps a migrated database
+    # identical to a new one. Matching matters on Postgres in particular —
+    # asyncpg binds the serialised value as json and would reject a TEXT column
+    # (mirrors the `projects.attachments JSON` migration above).
+    await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN timelapse_baseline JSON")
+
     # Migration: plate-clear-required notification opt-in (#2525). Off by
     # default — it fires after every print, at the same moment as the
     # print-complete alert. Postgres rejects `DEFAULT 0` for BOOLEAN.

+ 34 - 1
backend/app/core/logging_filters.py

@@ -1,4 +1,4 @@
-"""Logging filters for the Bambuddy log pipeline.
+"""Logging filters and redaction helpers for the Bambuddy log pipeline.
 
 Holds two filters: ``WriteRequestsOnlyFilter`` keeps the file-side
 uvicorn access log focused on state-changing HTTP methods, and
@@ -6,12 +6,45 @@ uvicorn access log focused on state-changing HTTP methods, and
 caused by Starlette's ``BaseHTTPMiddleware`` cancellation propagation
 (see the filter's docstring for details). Both live here so tests can
 import them without pulling in ``backend.app.main``'s startup graph.
+
+Also holds :data:`URL_CREDENTIALS_PATTERN` and
+:func:`redact_url_credentials`, the single place where the shape of a
+credentialed URL is defined for the whole backend.
 """
 
 from __future__ import annotations
 
 import asyncio
 import logging
+import re
+
+# ``scheme://user:secret@host`` — the only URL shape that carries a secret.
+# Both userinfo parts exclude ``/`` so the match can never run past the
+# authority into the path, and exclude whitespace so a wrapped log line can't
+# glue two URLs together. ``secret`` is otherwise unrestricted and greedy so
+# it reaches the *last* ``@`` before the path, which is where RFC 3986 ends
+# the userinfo — that keeps an unescaped ``@`` inside a password (legal in an
+# external camera URL) from leaving its tail in the log. Named groups let
+# callers choose how much to mask: the log pipeline keeps the username, the
+# support-bundle sanitizer drops it (see ``log_reader.sanitize_log_content``).
+URL_CREDENTIALS_PATTERN = re.compile(r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.\-]*://)(?P<user>[^/:@\s]+):(?P<secret>[^/\s]+)@")
+
+
+def redact_url_credentials(text: str | None) -> str | None:
+    """Mask the password in every ``scheme://user:secret@host`` URL in *text*.
+
+    Subprocesses echo their input URL back at us — ffmpeg prints the RTSP
+    input in its ``Input #0`` line, so logging its stderr verbatim publishes
+    the printer access code (or an external camera's password) into
+    ``bambuddy.log``, which users routinely attach to public issues.
+
+    The username, host, port and path survive so the line stays useful for
+    diagnosis; only the secret is replaced. Returns *text* unchanged when
+    there is nothing to mask, including ``None``/``""``.
+    """
+    if not text or "://" not in text or "@" not in text:
+        return text
+    return URL_CREDENTIALS_PATTERN.sub(r"\g<scheme>\g<user>:[REDACTED]@", text)
 
 
 class WriteRequestsOnlyFilter(logging.Filter):

+ 381 - 132
backend/app/main.py

@@ -2700,6 +2700,11 @@ async def on_print_start(printer_id: int, data: dict):
                 # scanner runs fresh; also unlink the old video file so reprints
                 # don't accumulate orphans in the archive directory. Photos list
                 # is left alone — accumulating one finish photo per run is fine.
+                # The print-start baseline (#2704) is stale for the same reason:
+                # it describes the printer before the previous run. The capture
+                # below overwrites it, but clear it here too so an early failure
+                # can't leave the scan diffing against the wrong snapshot.
+                archive.timelapse_baseline = None
                 stale_timelapse_relpath = archive.timelapse_path
                 if stale_timelapse_relpath:
                     archive.timelapse_path = None
@@ -2838,7 +2843,7 @@ async def on_print_start(printer_id: int, data: dict):
                 # falls into its "take baseline now" fallback, which snapshots
                 # AFTER the new MP4 already exists and never matches a diff
                 # (#1403 follow-up — see pwostran's 2026-05-18 support bundle).
-                await _capture_timelapse_baseline_at_start(printer, printer_id, logger)
+                await _capture_timelapse_baseline_at_start(printer, printer_id, logger, archive_id=archive.id)
 
             return  # Skip creating a new archive
 
@@ -3488,7 +3493,7 @@ async def on_print_start(printer_id: int, data: dict):
                     logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
 
                 # Capture timelapse file baseline for snapshot-diff on completion
-                await _capture_timelapse_baseline_at_start(printer, printer_id, logger)
+                await _capture_timelapse_baseline_at_start(printer, printer_id, logger, archive_id=archive.id)
         finally:
             # Keep temp_path around until print completes so the cover endpoint
             # can reuse it (#972). Cache eviction in on_print_complete deletes
@@ -3501,6 +3506,62 @@ async def on_print_start(printer_id: int, data: dict):
 
 _TIMELAPSE_VIDEO_EXTENSIONS = (".mp4", ".avi")
 
+# Poll schedule for the post-print timelapse scan (#2704). Module-level so
+# tests can shrink them without waiting out real delays.
+#
+# This replaced a fixed [5, 10, 20, 30] retry ladder, i.e. roughly 65 s of
+# looking. Across 247 support bundles the attempt that found the video was #1
+# 272 times, then 17 / 13 / 13 — a flat tail against the cutoff rather than a
+# decaying one, which is the signature of a budget that expires while files are
+# still arriving. 457 scans were scheduled and only 262 ever attached. Big
+# prints make big videos and the printer writes them after the print ends, so
+# the poll now runs for minutes and costs one FTP LIST per round.
+_TIMELAPSE_SCAN_FIRST_DELAY_SECONDS: float = 5.0
+_TIMELAPSE_SCAN_POLL_INTERVAL_SECONDS: float = 30.0
+_TIMELAPSE_SCAN_TIMEOUT_SECONDS: float = 900.0
+
+
+def _timelapse_scan_max_attempts() -> int:
+    """Round cap for the poll, derived from the wall-clock budget.
+
+    The deadline alone is not a sufficient bound: it assumes each round really
+    waits, which stops being true the moment ``asyncio.sleep`` is patched out,
+    and an FTP list that fails immediately would otherwise spin against the
+    printer at full speed for the whole window. Whichever bound is reached
+    first ends the poll.
+    """
+    if _TIMELAPSE_SCAN_POLL_INTERVAL_SECONDS <= 0:
+        # A zero interval makes the wall-clock budget meaningless; fall back to
+        # the round count the production interval would have given.
+        return 32
+    return max(1, int(_TIMELAPSE_SCAN_TIMEOUT_SECONDS // _TIMELAPSE_SCAN_POLL_INTERVAL_SECONDS) + 1)
+
+
+async def _claimed_timelapse_names(db, printer_id: int, exclude_archive_id: int) -> set[str]:
+    """Video filenames already attached to some other archive of this printer.
+
+    Used to disambiguate when more than one file is new since the baseline —
+    which happens when a previous print's video landed after this print's
+    baseline was taken. Ordering the candidates would be the obvious fix and is
+    the wrong one: it can only be done on mtime or on the filename timestamp,
+    both of which come from the printer's own clock, and a LAN-only printer
+    can't reach Bambu's NTP server. Exclusion needs no clock at all.
+
+    ``attach_timelapse`` saves the video into the archive directory under the
+    printer's original filename, and the later MP4 conversion keeps the stem,
+    so the stem of ``timelapse_path`` recovers what was claimed.
+    """
+    from backend.app.models.archive import PrintArchive
+
+    rows = await db.execute(
+        select(PrintArchive.timelapse_path).where(
+            PrintArchive.printer_id == printer_id,
+            PrintArchive.id != exclude_archive_id,
+            PrintArchive.timelapse_path.is_not(None),
+        )
+    )
+    return {Path(p).stem for p in rows.scalars().all() if p}
+
 
 async def _list_timelapse_videos(printer) -> tuple[list[dict], str | None]:
     """List video files from printer's timelapse directory.
@@ -3533,7 +3594,9 @@ async def _list_timelapse_videos(printer) -> tuple[list[dict], str | None]:
     return [], None
 
 
-async def _capture_timelapse_baseline_at_start(printer, printer_id: int, logger: logging.Logger) -> None:
+async def _capture_timelapse_baseline_at_start(
+    printer, printer_id: int, logger: logging.Logger, archive_id: int | None = None
+) -> None:
     """Snapshot the printer's timelapse directory at print start so the
     completion-time scan can pick the new file by set-difference.
 
@@ -3546,39 +3609,69 @@ async def _capture_timelapse_baseline_at_start(printer, printer_id: int, logger:
 
     Bambu printers in LAN-only mode don't sync NTP, so mtime ordering is
     unreliable — the snapshot-diff approach sidesteps that entirely.
+
+    When ``archive_id`` is known the baseline is also written to the archive
+    row, so it survives a restart and the manual "Scan for Timelapse" button
+    can run the same diff instead of falling back to clock-based matching
+    (#2704). Only baselines taken at print start are persisted — one taken at
+    completion already contains the new video and would poison a later scan.
     """
+    names: set[str] | None = None
     try:
         baseline_files, _ = await _list_timelapse_videos(printer)
-        _timelapse_baselines[printer_id] = {f.get("name", "") for f in baseline_files}
+        names = {f.get("name", "") for f in baseline_files}
+        _timelapse_baselines[printer_id] = names
         logger.info(
             "[TIMELAPSE] Baseline at print start: %s video files for printer %s",
-            len(_timelapse_baselines[printer_id]),
+            len(names),
             printer_id,
         )
     except Exception as e:
         logger.warning("[TIMELAPSE] Failed to capture baseline at print start: %s", e)
 
+    if archive_id is None:
+        return
+    try:
+        async with async_session() as db:
+            from backend.app.models.archive import PrintArchive
 
-async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[str] | None = None):
-    """
-    Scan for timelapse with retries using a snapshot-diff approach.
-
-    Instead of picking the "most recent by mtime" (unreliable when the printer
-    clock is wrong in LAN-only mode), we snapshot existing MP4 filenames BEFORE
-    waiting, then look for any NEW filename that appears after each delay.
+            archive = await db.get(PrintArchive, archive_id)
+            if archive is not None:
+                # Written even when the listing failed, and then as NULL. A
+                # reprint reuses the archive row, so leaving the previous run's
+                # baseline in place would have the scan diff this print against
+                # the state of the printer before the *last* one — and a stale
+                # baseline reads as authoritative, where NULL correctly falls
+                # back to a fresh snapshot.
+                archive.timelapse_baseline = sorted(names) if names is not None else None
+                await db.commit()
+    except Exception as e:
+        # In-memory baseline still covers the normal completion path.
+        logger.warning("[TIMELAPSE] Failed to persist baseline for archive %s: %s", archive_id, e)
 
-    If baseline_names is provided (captured at print start), it is used directly.
-    Otherwise falls back to taking a baseline at completion time (best-effort
-    for prints started before app restart).
 
-    Falls back to name-matching (print name contained in MP4 filename) if no
-    new file appears after all retries.
+async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[str] | None = None):
+    """Poll the printer for this print's timelapse and attach it.
+
+    Snapshot diff, not timestamp matching: a printer in LAN-only mode cannot
+    reach Bambu's NTP server, so the clock behind both the filename and the FTP
+    mtime is arbitrarily wrong — one reporter's P1S was six and a half days out
+    (#2704). Comparing the current listing against the set of filenames that
+    existed when the print started needs no clock at all, because the printer
+    writes the video only once the print has ended.
+
+    Baseline precedence: the caller's in-memory set, then the one persisted on
+    the archive at print start, then a snapshot taken now. The last of those is
+    a poor substitute — by completion the new video may already be on the card,
+    in which case it lands in the "baseline" and no diff can ever match — but it
+    is all that is available for a print that began before Bambuddy started.
+
+    On success the video is deleted from the printer, which keeps ``/timelapse``
+    down to the unclaimed files and makes the next diff unambiguous.
     """
-    from pathlib import Path
-
     logger = logging.getLogger(__name__)
 
-    # --- Phase 1: Take baseline snapshot of existing timelapse files ---
+    # --- Phase 1: establish the baseline -------------------------------------
     try:
         async with async_session() as db:
             from backend.app.models.printer import Printer
@@ -3597,14 +3690,20 @@ async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[
                 return
 
             if baseline_names is not None:
-                # Use pre-captured baseline from print start (no race condition)
                 logger.info(
                     "[TIMELAPSE] Using print-start baseline: %s existing video files for archive %s",
                     len(baseline_names),
                     archive_id,
                 )
+            elif archive.timelapse_baseline is not None:
+                # Persisted at print start — survives a restart mid-print.
+                baseline_names = set(archive.timelapse_baseline)
+                logger.info(
+                    "[TIMELAPSE] Using stored baseline: %s existing video files for archive %s",
+                    len(baseline_names),
+                    archive_id,
+                )
             else:
-                # Fallback: take baseline now (e.g. app restarted mid-print)
                 result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
                 printer = result.scalar_one_or_none()
                 if not printer:
@@ -3619,144 +3718,208 @@ async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[
                     archive_id,
                 )
 
-            # Derive base_name for name-matching fallback
-            base_name = Path(archive.filename).stem if archive.filename else ""
-            if base_name.endswith(".gcode"):
-                base_name = base_name[:-6]
-
     except Exception as e:
         logger.warning("[TIMELAPSE] Failed to take baseline snapshot for archive %s: %s", archive_id, e)
         return
 
-    # --- Phase 2: Retry loop — look for NEW files that weren't in baseline ---
-    retry_delays = [5, 10, 20, 30]
+    # --- Phase 2: poll for a file that was not there when the print began -----
+    deadline = time.monotonic() + _TIMELAPSE_SCAN_TIMEOUT_SECONDS
+    max_attempts = _timelapse_scan_max_attempts()
+    seen_names: set[str] = set()
+    delay = _TIMELAPSE_SCAN_FIRST_DELAY_SECONDS
+    attempt = 0
 
-    for attempt, delay in enumerate(retry_delays, 1):
-        logger.info(
-            "[TIMELAPSE] Attempt %s/%s: waiting %ss before scanning for archive %s",
-            attempt,
-            len(retry_delays),
-            delay,
-            archive_id,
-        )
+    while True:
         await asyncio.sleep(delay)
+        delay = _TIMELAPSE_SCAN_POLL_INTERVAL_SECONDS
+        attempt += 1
 
         try:
             from backend.app.models.printer import Printer
-            from backend.app.services.bambu_ftp import download_file_bytes_async
 
             # Read phase: fetch archive + printer in a short session and release
             # the pooled connection BEFORE the FTP list/download below. Holding it
             # across the FTP round-trips left one connection idle-in-transaction per
-            # in-flight scan — ×4 retries, per completed print (issue #2572).
+            # in-flight scan (issue #2572).
             async with async_session() as db:
                 service = ArchiveService(db)
                 archive = await service.get_archive(archive_id)
 
                 if not archive:
-                    logger.warning("[TIMELAPSE] Archive %s not found, stopping retries", archive_id)
+                    logger.warning("[TIMELAPSE] Archive %s not found, stopping poll", archive_id)
                     return
                 if archive.timelapse_path:
-                    logger.info("[TIMELAPSE] Archive %s already has timelapse attached, stopping retries", archive_id)
+                    logger.info("[TIMELAPSE] Archive %s already has timelapse attached, stopping poll", archive_id)
                     return
 
                 result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
                 printer = result.scalar_one_or_none()
                 if not printer:
-                    logger.warning("[TIMELAPSE] Printer not found for archive %s, stopping retries", archive_id)
+                    logger.warning("[TIMELAPSE] Printer not found for archive %s, stopping poll", archive_id)
                     return
 
+                claimed = await _claimed_timelapse_names(db, archive.printer_id, archive_id)
+
             # I/O phase (no DB connection held): FTP list + download.
             video_files, found_path = await _list_timelapse_videos(printer)
 
-            if not video_files:
-                logger.info("[TIMELAPSE] Attempt %s: No video files found, will retry", attempt)
-                continue
-
-            logger.info("[TIMELAPSE] Attempt %s: Found %s video files in %s", attempt, len(video_files), found_path)
-            for f in video_files[:5]:
-                logger.info("[TIMELAPSE]   - %s", f.get("name"))
-
-            # Find files that are NEW (not in baseline snapshot)
-            new_files = [f for f in video_files if f.get("name", "") not in baseline_names]
-
-            if new_files:
-                # Pick the first new file (there should typically be exactly one)
-                target = new_files[0]
-                file_name = target.get("name")
-                remote_path = target.get("path") or f"/timelapse/{file_name}"
-                logger.info(
-                    "[TIMELAPSE] Attempt %s: New file detected: %s (downloading for archive %s)",
-                    attempt,
-                    file_name,
-                    archive_id,
+            # The poll can run for dozens of rounds, so only narrate a round
+            # that saw something change. Repeating the whole listing every 30 s
+            # would bury the one interesting line in the support bundle.
+            names_now = {f.get("name", "") for f in video_files}
+            changed = attempt == 1 or names_now != seen_names
+            seen_names = names_now
+            speak = logger.info if changed else logger.debug
+
+            if video_files:
+                speak("[TIMELAPSE] Attempt %s: Found %s video files in %s", attempt, len(video_files), found_path)
+                if changed:
+                    for f in video_files[:5]:
+                        logger.info("[TIMELAPSE]   - %s", f.get("name"))
+
+                attached = await _attach_first_unclaimed_timelapse(
+                    archive_id, printer, video_files, baseline_names, claimed, attempt, logger, quiet=not changed
                 )
-
-                timelapse_data = await download_file_bytes_async(
-                    printer.ip_address, printer.access_code, remote_path, printer_model=printer.model
-                )
-                if timelapse_data:
-                    # Write phase: attach in a fresh short-lived session.
-                    async with async_session() as db:
-                        success = await ArchiveService(db).attach_timelapse(archive_id, timelapse_data, file_name)
-                    if success:
-                        logger.info("[TIMELAPSE] Successfully attached timelapse to archive %s", archive_id)
-                        await ws_manager.send_archive_updated({"id": archive_id, "timelapse_attached": True})
-                        return
-                    else:
-                        logger.warning("[TIMELAPSE] Failed to attach timelapse to archive %s", archive_id)
-                else:
-                    logger.warning("[TIMELAPSE] Attempt %s: Failed to download new file, will retry", attempt)
+                if attached:
+                    return
             else:
-                logger.info("[TIMELAPSE] Attempt %s: No new files since baseline, will retry", attempt)
+                speak("[TIMELAPSE] Attempt %s: No video files found, will retry", attempt)
 
         except Exception as e:
             logger.warning("[TIMELAPSE] Attempt %s failed with error: %s", attempt, e)
 
-    # --- Phase 3: Fallback — try name matching against all files ---
-    if base_name:
-        logger.info("[TIMELAPSE] Retries exhausted, trying name-match fallback for '%s'", base_name)
-        try:
-            from backend.app.models.printer import Printer
-            from backend.app.services.bambu_ftp import download_file_bytes_async
+        if attempt >= max_attempts or time.monotonic() >= deadline:
+            break
 
-            # Read phase: short session, released before the FTP work (issue #2572).
-            async with async_session() as db:
-                service = ArchiveService(db)
-                archive = await service.get_archive(archive_id)
-                if not archive or archive.timelapse_path:
-                    return
+    # No name-match fallback: it compared the print name against the filename,
+    # and Bambu firmware only ever writes "video_<timestamp>". Across 247 support
+    # bundles it fired 159 times and matched zero times, so all it added was a
+    # misleading log line before giving up.
+    logger.warning(
+        "[TIMELAPSE] No new video appeared for archive %s within %ss, giving up",
+        archive_id,
+        int(_TIMELAPSE_SCAN_TIMEOUT_SECONDS),
+    )
 
-                result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
-                printer = result.scalar_one_or_none()
-                if not printer:
-                    return
 
-            # I/O phase (no DB connection held): FTP list + download.
-            video_files, found_path = await _list_timelapse_videos(printer)
-            for f in video_files:
-                fname = f.get("name", "")
-                if base_name.lower() in fname.lower():
-                    remote_path = f.get("path") or f"/timelapse/{fname}"
-                    logger.info("[TIMELAPSE] Name-match fallback: '%s' matches '%s'", base_name, fname)
-
-                    timelapse_data = await download_file_bytes_async(
-                        printer.ip_address, printer.access_code, remote_path, printer_model=printer.model
-                    )
-                    if timelapse_data:
-                        # Write phase: attach in a fresh short-lived session.
-                        async with async_session() as db:
-                            success = await ArchiveService(db).attach_timelapse(archive_id, timelapse_data, fname)
-                        if success:
-                            logger.info("[TIMELAPSE] Name-match fallback attached timelapse to archive %s", archive_id)
-                            await ws_manager.send_archive_updated({"id": archive_id, "timelapse_attached": True})
-                            return
-                    break  # Only try the first name match
+async def _attach_first_unclaimed_timelapse(
+    archive_id: int,
+    printer,
+    video_files: list[dict],
+    baseline_names: set[str],
+    claimed: set[str],
+    attempt: int,
+    logger: logging.Logger,
+    *,
+    quiet: bool = False,
+) -> bool:
+    """Download and attach the one video that belongs to this print.
+
+    A candidate is any file absent from the print-start baseline. More than one
+    can qualify when a previous print's video landed late, after this print's
+    baseline was taken — those are filtered out by name, because they are
+    already attached to another archive. Sorting the candidates instead would
+    mean sorting on mtime or on the filename timestamp, both of which come from
+    the printer's unsynced clock.
+
+    Returns True once a video is attached. The printer's copy is deleted only
+    after the attach succeeds on bytes whose length matched the listing.
+
+    ``quiet`` downgrades the "nothing yet" lines to DEBUG when the caller has
+    already seen this exact listing — the poll runs for many rounds and only the
+    rounds where something changed are worth an INFO line.
+    """
+    from backend.app.services.bambu_ftp import (
+        delete_archived_timelapse,
+        download_file_bytes_async,
+        remote_file_settled,
+    )
 
-        except Exception as e:
-            logger.warning("[TIMELAPSE] Name-match fallback failed: %s", e)
+    speak = logger.debug if quiet else logger.info
+
+    new_files = [f for f in video_files if f.get("name", "") not in baseline_names]
+    if not new_files:
+        speak("[TIMELAPSE] Attempt %s: No new files since baseline, will retry", attempt)
+        return False
+
+    candidates = [f for f in new_files if Path(f.get("name", "")).stem not in claimed]
+    if not candidates:
+        speak(
+            "[TIMELAPSE] Attempt %s: %s new file(s), all already attached to other archives, will retry",
+            attempt,
+            len(new_files),
+        )
+        return False
+    if len(candidates) > 1:
+        logger.warning(
+            "[TIMELAPSE] Attempt %s: %s unclaimed new files (%s) — taking the first; "
+            "the rest stay on the printer for manual selection",
+            attempt,
+            len(candidates),
+            ", ".join(str(f.get("name")) for f in candidates),
+        )
+
+    target = candidates[0]
+    file_name = target.get("name")
+    remote_path = target.get("path") or f"/timelapse/{file_name}"
+    logger.info(
+        "[TIMELAPSE] Attempt %s: New file detected: %s (downloading for archive %s)",
+        attempt,
+        file_name,
+        archive_id,
+    )
+
+    # The listing always carries a size (`list_files` skips entries it can't
+    # parse), but read it explicitly: the delete below is destructive and must
+    # depend on a size we actually had, not on one we hoped was there.
+    expected_size = target.get("size")
+
+    timelapse_data = await download_file_bytes_async(
+        printer.ip_address,
+        printer.access_code,
+        remote_path,
+        printer_model=printer.model,
+        expected_size=expected_size,
+    )
+    if not timelapse_data:
+        # Short or failed transfer. The printer keeps its copy, so the next
+        # round can try again — which is exactly why the delete below is
+        # gated on a verified download.
+        logger.warning("[TIMELAPSE] Attempt %s: Failed to download new file, will retry", attempt)
+        return False
 
-    logger.warning("[TIMELAPSE] All attempts exhausted for archive %s, giving up", archive_id)
+    # The length check above proves we got what the listing said, not that the
+    # printer had finished writing. A video still being written can be listed
+    # short, served short, and pass — so confirm it has stopped growing before
+    # committing to it and deleting the original (#2704).
+    if not await remote_file_settled(
+        printer.ip_address,
+        printer.access_code,
+        remote_path,
+        len(timelapse_data),
+        printer_model=printer.model,
+    ):
+        return False
+
+    # Write phase: attach in a fresh short-lived session.
+    async with async_session() as db:
+        success = await ArchiveService(db).attach_timelapse(archive_id, timelapse_data, file_name)
+    if not success:
+        logger.warning("[TIMELAPSE] Failed to attach timelapse to archive %s", archive_id)
+        return False
+
+    logger.info("[TIMELAPSE] Successfully attached timelapse to archive %s", archive_id)
+    await ws_manager.send_archive_updated({"id": archive_id, "timelapse_attached": True})
+
+    await delete_archived_timelapse(
+        printer.ip_address,
+        printer.access_code,
+        remote_path,
+        verified=expected_size is not None,
+        printer_model=printer.model,
+        printer_name=printer.name,
+    )
+    return True
 
 
 # Defaults for the finish-photo-from-timelapse polling loop (#1397). These are
@@ -3764,11 +3927,25 @@ async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[
 _FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS: float = 3.0
 _FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS: float = 60.0
 
+# How long the *background* upgrade keeps waiting after the notification has
+# already gone out (#2704 follow-up). The short bound above exists so a slow
+# printer can't hold up the print-complete notification; this one exists so the
+# archive still ends up with the better frame afterwards.
+#
+# Measured across 261 attaches in the support bundles, the video lands a median
+# 13s after the print ends — but the P1 series writes MJPEG AVI rather than
+# H.264 MP4 and serves it slowly, so its p90 is 167s and the worst observed case
+# was 546s. Every other model was inside 26s. The long budget is therefore
+# almost entirely for P1-series users; on everything else the short wait already
+# wins and this task never runs.
+_FINISH_PHOTO_UPGRADE_TIMEOUT_SECONDS: float = 900.0
+
 
 async def _capture_finish_photo_from_timelapse(
     archive_id: int,
     archive_dir: Path,
-) -> str | None:
+    timeout: float | None = None,
+) -> tuple[str | None, bool]:
     """Wait for the per-print timelapse to land on the archive and extract its
     last frame as the finish photo (#1397).
 
@@ -3779,10 +3956,14 @@ async def _capture_finish_photo_from_timelapse(
 
     ``_scan_for_timelapse_with_retries`` runs in parallel and writes
     ``archive.timelapse_path`` when the file lands. This function polls for
-    that field. Returns the saved photo filename on success, or None if the
-    timelapse never arrives within the timeout / extraction fails / no
-    timelapse path was set — in which case the caller falls back to the
-    existing live-camera capture chain.
+    that field.
+
+    Returns ``(filename, still_pending)``. ``still_pending`` is True only when
+    the wait ran out with no video on the archive yet — i.e. the video may
+    still be coming and a later attempt could succeed. It is False when the
+    video landed (whether or not extraction worked), because in that case
+    waiting longer changes nothing. The caller uses that to decide between
+    falling back permanently and scheduling a background upgrade.
     """
     import uuid
 
@@ -3791,7 +3972,8 @@ async def _capture_finish_photo_from_timelapse(
 
     logger = logging.getLogger(__name__)
 
-    deadline = asyncio.get_event_loop().time() + _FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS
+    budget = _FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS if timeout is None else timeout
+    deadline = asyncio.get_event_loop().time() + budget
     poll_interval = _FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS
 
     while True:
@@ -3814,25 +3996,71 @@ async def _capture_finish_photo_from_timelapse(
                         video_path.name,
                         archive_id,
                     )
-                    return filename
+                    return filename, False
                 logger.warning(
                     "[PHOTO-BG] Timelapse %s landed but last-frame extraction failed for archive %s; falling back",
                     video_path.name,
                     archive_id,
                 )
-                return None
+                return None, False
 
         if asyncio.get_event_loop().time() >= deadline:
             logger.info(
                 "[PHOTO-BG] Timelapse for archive %s didn't land within %.0fs; falling back to live camera",
                 archive_id,
-                _FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS,
+                budget,
             )
-            return None
+            return None, True
 
         await asyncio.sleep(poll_interval)
 
 
+async def _upgrade_finish_photo_from_timelapse(archive_id: int, archive_dir: Path) -> None:
+    """Add the timelapse's last frame to an archive after the fact (#2704).
+
+    The print-complete notification waits only ~60s for the video, because
+    holding a notification for minutes is worse than sending it with a live
+    camera grab. On a P1-series printer the video often lands well after that,
+    so the archive used to be stuck with the live grab — which is taken at
+    ``gcode_state=FINISH``, after the end G-code has dropped the bed, and is
+    the worse photo of the two.
+
+    This keeps waiting in the background and, when the video arrives, extracts
+    the frame and puts it *first* in the archive's photo list, so opening the
+    gallery shows it. The live grab is deliberately kept: the notification that
+    already went out links to that exact file, and deleting it would leave a
+    broken image in Discord or Telegram.
+    """
+    logger = logging.getLogger(__name__)
+
+    filename, _ = await _capture_finish_photo_from_timelapse(
+        archive_id, archive_dir, timeout=_FINISH_PHOTO_UPGRADE_TIMEOUT_SECONDS
+    )
+    if not filename:
+        logger.info("[PHOTO-UPGRADE] No timelapse frame for archive %s; keeping the live grab", archive_id)
+        return
+
+    try:
+        async with async_session() as db:
+            from backend.app.models.archive import PrintArchive
+
+            archive = await db.get(PrintArchive, archive_id)
+            if archive is None:
+                return
+            photos = list(archive.photos or [])
+            if filename in photos:
+                return
+            # Front of the list: PhotoGalleryModal opens at index 0.
+            archive.photos = [filename, *photos]
+            await db.commit()
+    except Exception as e:
+        logger.warning("[PHOTO-UPGRADE] Failed to attach upgraded photo to archive %s: %s", archive_id, e)
+        return
+
+    logger.info("[PHOTO-UPGRADE] Archive %s now leads with the timelapse frame %s", archive_id, filename)
+    await ws_manager.send_archive_updated({"id": archive_id, "photo_added": filename})
+
+
 async def on_print_running_observed(printer_id: int, data: dict):
     """Restart-recovery: capture a fresh timelapse baseline for a print that
     started before Bambuddy came up.
@@ -5029,8 +5257,9 @@ async def on_print_complete(printer_id: int, data: dict):
                 printer.external_camera_enabled and printer.external_camera_url
             )
 
+            timelapse_still_pending = False
             if prefer_timelapse_source:
-                photo_filename = await _capture_finish_photo_from_timelapse(
+                photo_filename, timelapse_still_pending = await _capture_finish_photo_from_timelapse(
                     archive_id=archive_id,
                     archive_dir=archive_dir,
                 )
@@ -5134,8 +5363,28 @@ async def on_print_complete(printer_id: int, data: dict):
                         arch.photos = photos
                         await db.commit()
                 logger.info("[PHOTO-BG] Saved: %s", photo_filename)
-                return photo_filename
-            return None
+
+            # The short wait above is bounded so a slow printer can't hold up
+            # the print-complete notification, which is what the caller is
+            # blocking on. When it ran out with the video still on its way,
+            # keep waiting off to the side and add the better frame to the
+            # archive once it arrives (#2704 follow-up) — otherwise P1-series
+            # users, whose videos routinely take minutes to transfer, never get
+            # the pre-bed-drop framing this path exists to provide.
+            #
+            # Spawned here rather than at the point the wait gave up: both this
+            # function and the upgrade do a read-modify-write on `photos`, and
+            # the live-camera fallback above can take tens of seconds. Starting
+            # the upgrade before that write means the two can interleave and one
+            # silently drops the other's entry, leaving a JPEG on disk that the
+            # gallery never lists.
+            if timelapse_still_pending:
+                spawn_background_task(
+                    _upgrade_finish_photo_from_timelapse(archive_id, archive_dir),
+                    name=f"finish-photo-upgrade-{archive_id}",
+                )
+
+            return photo_filename
         except Exception as e:
             logger.warning("[PHOTO-BG] Failed: %s", e)
             return None

+ 10 - 0
backend/app/models/archive.py

@@ -32,6 +32,16 @@ class PrintArchive(Base):
     # both locally and on the printer's SD after extraction — the user
     # didn't opt in to a timelapse recording.
     bambuddy_forced_timelapse: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
+    # Video filenames present in the printer's /timelapse directory when this
+    # print started (#2704). The printer writes its video only at print end, so
+    # anything not in this list belongs to this print — a comparison that needs
+    # no clock, which matters because a LAN-only printer can't reach Bambu's NTP
+    # server and its filename timestamps are arbitrarily wrong. Persisted (not
+    # just held in memory) so the diff survives a restart and so the manual
+    # "Scan for Timelapse" button can use it instead of guessing from
+    # timestamps. NULL for archives predating this, and for baselines taken at
+    # completion time, which are useless by construction.
+    timelapse_baseline: Mapped[list | None] = mapped_column(JSON, nullable=True)
     source_3mf_path: Mapped[str | None] = mapped_column(String(500))  # Original project 3MF from slicer
     f3d_path: Mapped[str | None] = mapped_column(String(500))  # Fusion 360 design file
 

+ 148 - 4
backend/app/services/bambu_ftp.py

@@ -353,18 +353,43 @@ class BambuFTPClient:
 
         return files
 
-    def download_file(self, remote_path: str) -> bytes | None:
-        """Download a file from the printer."""
+    def download_file(self, remote_path: str, expected_size: int | None = None) -> bytes | None:
+        """Download a file from the printer.
+
+        ``expected_size`` is the byte count the directory listing reported for
+        this file. Pass it whenever a short read must not be mistaken for a
+        successful download: an FTPS data connection that closes early does
+        not always raise, so ``retrbinary`` can hand back a partial buffer that
+        looks like a perfectly good file to everything downstream. That is
+        tolerable when the printer keeps its copy, and not tolerable when the
+        caller goes on to delete the source (#2704).
+
+        A zero-byte result is always treated as a failure, matching
+        :meth:`download_to_file` — no caller has a use for an empty file.
+        """
         if not self._ftp:
             return None
 
         try:
             buffer = BytesIO()
             self._ftp.retrbinary(f"RETR {remote_path}", buffer.write)
-            return buffer.getvalue()
+            data = buffer.getvalue()
         except (OSError, ftplib.Error):
             return None
 
+        if not data:
+            logger.warning("FTP download returned 0 bytes for %s", remote_path)
+            return None
+        if expected_size is not None and len(data) != expected_size:
+            logger.warning(
+                "FTP download of %s is short: got %s bytes, listing reported %s — treating as failed",
+                remote_path,
+                len(data),
+                expected_size,
+            )
+            return None
+        return data
+
     def download_to_file(self, remote_path: str, local_path: Path) -> bool:
         """Download a file from the printer to local filesystem."""
         if not self._ftp:
@@ -1301,6 +1326,7 @@ async def download_file_bytes_async(
     socket_timeout: float | None = None,
     printer_model: str | None = None,
     timeout: float = 300.0,
+    expected_size: int | None = None,
 ) -> bytes | None:
     """Async wrapper for downloading file as bytes.
 
@@ -1313,6 +1339,9 @@ async def download_file_bytes_async(
             video, gcode) which can legitimately take minutes over slow Wi-Fi —
             the cap only guards against a permanently-starved pool, not a
             slow-but-progressing transfer.
+        expected_size: size from the directory listing; a mismatch fails the
+            download instead of returning a truncated file. See
+            :meth:`BambuFTPClient.download_file`.
     """
     loop = asyncio.get_event_loop()
 
@@ -1320,7 +1349,7 @@ async def download_file_bytes_async(
         client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
         if client.connect():
             try:
-                return client.download_file(remote_path)
+                return client.download_file(remote_path, expected_size=expected_size)
             finally:
                 client.disconnect()
         return None
@@ -1332,6 +1361,121 @@ async def download_file_bytes_async(
         return None
 
 
+async def remote_file_settled(
+    ip_address: str,
+    access_code: str,
+    remote_path: str,
+    downloaded_bytes: int,
+    *,
+    printer_model: str | None = None,
+) -> bool:
+    """Confirm the printer has finished writing the file we just downloaded.
+
+    Matching the download against the size from the directory listing proves we
+    received what the listing *said*, not that the file was *finished*. The
+    timelapse scan's first look happens seconds after the print ends, which is
+    exactly when the printer is writing the video — so a file still growing can
+    be listed at a partial size, served at that size, and pass the length check
+    as a complete video (#2704).
+
+    That was survivable while the printer kept its copy. It isn't now that a
+    successful attach deletes the source, so re-list afterwards: if the file has
+    grown, what we hold is a prefix and the caller should discard it and try
+    again on the next round.
+
+    Returns True when the remote file can no longer differ from what we hold —
+    the size still matches, or the file is gone from the listing entirely and
+    so cannot grow any further. Returns False when it has changed size, and on
+    a listing failure, because "we could not check" must not read as "safe to
+    delete".
+    """
+    directory, _, name = remote_path.rpartition("/")
+    files = await list_files_async(ip_address, access_code, directory or "/", printer_model=printer_model)
+    if not files:
+        logger.warning("[TIMELAPSE] Could not re-list %s to confirm %s is complete", directory or "/", name)
+        return False
+
+    for f in files:
+        if f.get("name") == name:
+            size = f.get("size")
+            if size == downloaded_bytes:
+                return True
+            logger.info(
+                "[TIMELAPSE] %s is still being written (%s bytes now, %s when downloaded) — will retry",
+                name,
+                size,
+                downloaded_bytes,
+            )
+            return False
+
+    # Vanished between the download and now. Nothing left that could grow, and
+    # nothing left to delete either.
+    logger.debug("[TIMELAPSE] %s is no longer on the printer after download", name)
+    return True
+
+
+async def delete_archived_timelapse(
+    ip_address: str,
+    access_code: str,
+    remote_path: str,
+    *,
+    verified: bool,
+    printer_model: str | None = None,
+    printer_name: str = "",
+) -> bool:
+    """Remove a timelapse from the printer once it is safely in the archive.
+
+    Call this only after the attach succeeded (#2704). Keeping ``/timelapse``
+    down to just the unclaimed videos is what makes the snapshot diff
+    unambiguous rather than merely usually-right, and it stops P1S cards
+    filling with AVIs.
+
+    ``verified`` must say whether the downloaded byte count was checked against
+    the size the directory listing reported. It is required rather than
+    defaulted because this is the one irreversible step in the flow: an FTPS
+    data connection that closes early does not always raise, so an unverified
+    transfer can be a partial file that looks complete, and deleting the source
+    would then destroy the only good copy. The check lives here rather than at
+    each call site so no future caller can omit it.
+
+    Best-effort otherwise: a printer that refuses the delete keeps its copy, the
+    diff still excludes that filename next time because it is attached to an
+    archive, and nothing else in the flow cares. Returns True only on an actual
+    delete or a 550 (already gone).
+    """
+    if not verified:
+        logger.warning(
+            "[TIMELAPSE] Not deleting %s from printer %s: the download was never size-checked",
+            remote_path,
+            printer_name,
+        )
+        return False
+
+    for attempt in range(1, 4):
+        try:
+            result = await delete_file_async(ip_address, access_code, remote_path, printer_model=printer_model)
+        except Exception as e:
+            result = DeleteResult.FAILED
+            logger.warning("[TIMELAPSE] Delete attempt %d/3 raised for %s: %s", attempt, remote_path, e)
+
+        if result == DeleteResult.DELETED:
+            logger.info("[TIMELAPSE] Deleted %s from printer %s after archiving", remote_path, printer_name)
+            return True
+        if result == DeleteResult.NOT_FOUND:
+            # 550 never recovers by waiting — the printer already cleaned up.
+            logger.debug("[TIMELAPSE] %s already gone from printer %s", remote_path, printer_name)
+            return True
+        if attempt < 3:
+            await asyncio.sleep(2)
+
+    logger.warning(
+        "[TIMELAPSE] Could not delete %s from printer %s (it stays on the card; the archive copy is unaffected)",
+        remote_path,
+        printer_name,
+    )
+    return False
+
+
 async def get_storage_info_async(
     ip_address: str,
     access_code: str,

+ 253 - 4
backend/app/services/bambu_mqtt.py

@@ -40,6 +40,20 @@ _AMS_MODULE_PREFIXES = ("ams/", "n3f/", "n3s/")
 # printer_manager.ACTIVE_PRINT_STATES and print_scheduler._ACTIVE_PRINT_STATES.
 _ACTIVE_PRINT_STATES = frozenset({"PREPARE", "SLICING", "RUNNING", "PAUSE"})
 
+# CONNACK reason codes that mean the printer actively refused our credentials,
+# as opposed to being unreachable or busy. Bambu speaks MQTT 3.1.1, whose
+# single-byte CONNACK return codes paho maps onto the v5 reason-code space:
+# return code 4 ("bad user name or password") -> 134, and 5 ("not authorized")
+# -> 135. Both mean the same thing in practice for a Bambu printer: the access
+# code (or, on some firmware, the serial used as the username) is wrong.
+_CONNACK_AUTH_REJECTED = frozenset({134, 135})
+
+# Short, stable slugs recorded on the client and surfaced to the connection
+# diagnostic as a `params.reason` variant. Deliberately not free text — the
+# frontend picks a localized message key off these.
+CONNECT_ERROR_AUTH_REJECTED = "auth_rejected"
+CONNECT_ERROR_REFUSED = "refused"
+
 
 def parse_ams_filament_backup_from_cfg(cfg_raw: object) -> bool | None:
     """Extract AMS Filament Backup state from a Bambu push_status ``print.cfg`` value.
@@ -147,9 +161,11 @@ def apply_tray_exist_bits(
     the HT keeps echoing stale ``tray_type`` and its ``state`` is firmware-variant
     (#2670). Verified against OrcaSlicer ``DevFilaSystem.cpp``
     (``is_exists = tray_exist_bits >> (16 + (ams_id-128))``) and a live H2D
-    capture (HT-A → bit 16). The A2L-Lite (normalised to id 6 upstream) lands at
-    bits 24-27 via the regular ``ams_id * 4`` formula, matching OrcaSlicer's
-    ``AMS_LITE_MIXED`` offset, so it needs no special case here.
+    capture (HT-A → bit 16). The A2L-Lite lands at bits 24-27 via the regular
+    ``ams_id * 4`` formula, matching OrcaSlicer's ``AMS_LITE_MIXED`` offset; the
+    unit id is folded through ``normalize_am_unit_id`` first so callers holding
+    the raw physical id 16 get the same bit base as callers holding the
+    normalised 6 (#2697).
 
     `tray_exist_bits_str` is expected as a hex string (firmware sends it that
     way). Ints are tolerated for defensive symmetry but typically not seen
@@ -192,6 +208,13 @@ def apply_tray_exist_bits(
             continue
         if not isinstance(ams_id, int):
             continue
+        # The A2L AMS-Lite reaches this helper under either id: `_handle_ams_data`
+        # normalises 16 -> 6 before calling, but the VP bridge parses the raw
+        # printer payload itself (`mqtt_bridge._on_printer_raw`) and still holds
+        # the physical 16. Both mean bit base 24, so fold them together here
+        # rather than relying on every caller to normalise first — reading 16 as
+        # 16*4 = bit 64 finds nothing set and wipes every A2L slot (#2697).
+        ams_id = normalize_am_unit_id(ams_id)
         # AMS-HT (n3s, id 128-135): single tray, presence bit at 16+(ams_id-128).
         # Regular AMS (and the A2L-Lite normalised to id 6): ams_id*4 + tray_id.
         # Anything outside those ranges has no known bit layout — don't guess it.
@@ -543,6 +566,58 @@ def get_stage_name(stage: int) -> str:
     return STAGE_NAMES.get(stage, f"Unknown stage ({stage})")
 
 
+# #2547 end-of-print telemetry probe.
+#
+# The finish photo needs a "printing is done, toolhead parked, filament unload
+# not started yet" moment. ``stg_cur=22`` was meant to be that moment (#1721)
+# but fires on no model in the field: across 247 support bundles there is not a
+# single ``FINISH PHOTO MOMENT (stage-22)``, including the 2026-06-13..07-08
+# window where it was the only pre-FINISH trigger in the code (104 captures on
+# A1, A1 Mini, H2C, H2D, P1S, P2S, X1C, X2D — all of them the FINISH fallback).
+#
+# We can't design a replacement from bundles we already have, because out of
+# this window Bambuddy only ever parses ``stg_cur`` and ``mc_print_sub_stage``;
+# every other stage/action field is dropped unread. The obvious candidates
+# (``print_real_action``, ``mc_action``, ``mc_stage``) are also absent from
+# A1/A1 Mini/P1S payloads, so none of them can be the universal answer on its
+# own. Dumping the raw values for the window between the last object layer and
+# ``gcode_state=FINISH`` lets one debug bundle per model settle what — if
+# anything — marks that moment.
+#
+# Every field here is machine telemetry (stage codes, counters, bitfields).
+# Nothing identifying, and nothing that could carry an access code.
+_END_OF_PRINT_PROBE_FIELDS = (
+    "gcode_state",
+    "state",
+    "print_error",
+    "stg_cur",
+    "stg",
+    "stg_cd",
+    "mc_print_stage",
+    "mc_print_sub_stage",
+    "mc_action",
+    "mc_stage",
+    "print_real_action",
+    "print_gcode_action",
+    "spd_lvl",
+    "mc_percent",
+    "mc_remaining_time",
+    "layer_num",
+    "total_layer_num",
+    "home_flag",
+    "prepare_per",
+)
+
+# Frame budget for one print's probe. A long final layer can hold the window
+# open for minutes at ~1 frame/second; this stops a single print from filling
+# the log the user then has to upload.
+_END_OF_PRINT_PROBE_MAX_FRAMES = 400
+
+# States that close the window. FINISH is the interesting one — the probe's
+# whole job is to show what happened in the run-up to it.
+_END_OF_PRINT_PROBE_CLOSING_STATES = frozenset({"FINISH", "FAILED", "IDLE", "PREPARE"})
+
+
 class BambuMQTTClient:
     """MQTT client for Bambu Lab printer communication."""
 
@@ -646,6 +721,12 @@ class BambuMQTTClient:
         # and the FINISH-state fallback don't both fire on the same
         # print. Reset to False on every print start.
         self._finish_photo_captured: bool = False
+        # #2547 end-of-print telemetry probe state. `_armed` is cleared once the
+        # window has run for a print so a late FINISH re-send can't reopen it.
+        self._eop_probe_armed: bool = True
+        self._eop_probe_open: bool = False
+        self._eop_probe_frames: int = 0
+        self._eop_probe_last: dict = {}
         self._last_valid_progress: float = 0.0  # Last non-zero progress (firmware resets on cancel)
         self._last_valid_layer_num: int = 0  # Last non-zero layer (firmware resets on cancel)
         # The subtask_id minted for the most recent start_print() command. The
@@ -713,6 +794,18 @@ class BambuMQTTClient:
         # See normalize_am_unit_id / a2l_lite_wire_ids and memory a2l-am-unit-16.
         self._has_a2l_am_unit: bool = False
 
+        # Why the last connection attempt was refused by the printer, or None
+        # when we have never seen a CONNACK failure since the last success.
+        # Without this a rejected access code was completely invisible: paho
+        # reports the follow-up disconnect as the generic "Unspecified error"
+        # and `_on_connect`'s failure branch used to log nothing at all, so a
+        # printer stuck in a reconnect loop looked identical whether it was
+        # powered off, on the wrong IP, or refusing our credentials (#2698).
+        # One of the CONNECT_ERROR_* slugs; the paired name is the paho reason
+        # string, kept for the log line only.
+        self.last_connect_error: str | None = None
+        self.last_connect_error_name: str | None = None
+
         # Request topic subscription tracking
         # Some printer MQTT brokers (e.g. P1S, A1) reject subscriptions to the request
         # topic by killing the TCP connection. We detect this and gracefully degrade.
@@ -963,6 +1056,8 @@ class BambuMQTTClient:
     def _on_connect(self, client, userdata, flags, rc, properties=None):
         if rc == 0:
             self.state.connected = True
+            self.last_connect_error = None
+            self.last_connect_error_name = None
             self._stale_reconnecting = False  # Clear stale-reconnect flag on successful connect
             # A dropped-and-restored MQTT session means the presumed power-off was
             # real (or at least that the printer restarted): there is nothing
@@ -1020,6 +1115,43 @@ class BambuMQTTClient:
                 self.on_state_change(self.state)
         else:
             self.state.connected = False
+            self._record_connect_refusal(rc)
+
+    def _record_connect_refusal(self, rc) -> None:
+        """Log and remember why the printer refused the MQTT connection.
+
+        The failure branch of ``_on_connect`` used to be a bare
+        ``connected = False``, which threw away the only signal that says
+        *why* a printer never comes online. The user-visible result was a
+        30-second reconnect loop logging nothing but paho's generic
+        ``MQTT disconnected: rc=Unspecified error`` — indistinguishable from a
+        powered-off printer, so "my printer won't print" reports could not be
+        triaged without a round trip (#2698).
+
+        Never logs the access code itself; the code is the likely culprit but
+        printing it would put a credential in every support bundle.
+        """
+        code = getattr(rc, "value", rc)
+        name = rc.getName() if hasattr(rc, "getName") else str(rc)
+        self.last_connect_error_name = name
+        if isinstance(code, int) and code in _CONNACK_AUTH_REJECTED:
+            self.last_connect_error = CONNECT_ERROR_AUTH_REJECTED
+            logger.warning(
+                "[%s] MQTT connection refused by the printer: %s (code %s). The access code "
+                "or serial number is wrong — the access code changes every time LAN Only or "
+                "Developer Mode is toggled, so re-read it from the printer's screen.",
+                self.serial_number,
+                name,
+                code,
+            )
+        else:
+            self.last_connect_error = CONNECT_ERROR_REFUSED
+            logger.warning(
+                "[%s] MQTT connection refused by the printer: %s (code %s).",
+                self.serial_number,
+                name,
+                code,
+            )
 
     def _on_subscribe(self, client, userdata, mid, reason_code_list, properties=None):
         """Handle SUBACK responses to detect request topic subscription rejection."""
@@ -1076,7 +1208,21 @@ class BambuMQTTClient:
             )
             return
 
-        logger.warning("[%s] MQTT disconnected: rc=%s, flags=%s", self.serial_number, rc, disconnect_flags)
+        # Carry the last CONNACK refusal into the disconnect line. paho reports
+        # the drop that follows a refused CONNACK as "Unspecified error", so on
+        # its own this line says nothing useful about a printer that is looping
+        # on bad credentials — and this is the line that fills a support bundle
+        # (#2698).
+        if self.last_connect_error:
+            logger.warning(
+                "[%s] MQTT disconnected: rc=%s, flags=%s (last connection attempt was refused: %s)",
+                self.serial_number,
+                rc,
+                disconnect_flags,
+                self.last_connect_error_name,
+            )
+        else:
+            logger.warning("[%s] MQTT disconnected: rc=%s, flags=%s", self.serial_number, rc, disconnect_flags)
 
         # Detect if request topic subscription caused the disconnect.
         # If we just subscribed and got disconnected before any SUBACK confirmation,
@@ -2702,10 +2848,108 @@ class BambuMQTTClient:
             except Exception:
                 logger.exception("[%s] on_assignment_verified callback failed", self.serial_number)
 
+    @staticmethod
+    def _probe_number(value, fallback: float | None = None) -> float | None:
+        """Coerce a telemetry field to a number, or return `fallback`.
+
+        Firmware is inconsistent about whether these arrive as ints or as
+        numeric strings, and the probe must never raise on a surprise type.
+        """
+        try:
+            return float(value)
+        except (TypeError, ValueError):
+            return fallback
+
+    def _probe_end_of_print(self, data: dict) -> None:
+        """Log raw end-of-print telemetry for one print at DEBUG (#2547).
+
+        Opens on the first frame that looks like end-of-print (last object
+        layer reached, progress at 99+, or no remaining time), then logs each
+        frame in which any probed field changed, and closes on the transition
+        out of RUNNING. Armed once per print — see the module-level comment on
+        ``_END_OF_PRINT_PROBE_FIELDS`` for why this window is the one we can't
+        currently see into.
+
+        Read-only with respect to printer state: this is instrumentation, and
+        nothing downstream may come to depend on it.
+        """
+        if not logger.isEnabledFor(logging.DEBUG):
+            return
+        if not self._eop_probe_open and not (self._eop_probe_armed and self._was_running):
+            return
+
+        present = {k: data[k] for k in _END_OF_PRINT_PROBE_FIELDS if k in data}
+        if not present:
+            return
+
+        if not self._eop_probe_open:
+            # Open on any end-of-print signal. Read from the raw frame first so
+            # the frame that *carries* the signal is itself captured — state
+            # fields are only updated further down this same call.
+            layer = self._probe_number(data.get("layer_num"), self.state.layer_num) or 0
+            total = self._probe_number(data.get("total_layer_num"), self.state.total_layers) or 0
+            percent = self._probe_number(data.get("mc_percent"), self.state.progress) or 0
+            remaining = self._probe_number(data.get("mc_remaining_time"), self.state.remaining_time)
+            at_last_layer = total > 0 and layer >= total
+            # `remaining <= 0` is only meaningful once the print has actually
+            # progressed — it reads 0 during the pre-print calibration too.
+            out_of_time = remaining is not None and remaining <= 0 and percent > 0
+            if not (at_last_layer or percent >= 99 or out_of_time):
+                return
+            self._eop_probe_open = True
+            self._eop_probe_frames = 0
+            self._eop_probe_last = {}
+            logger.debug(
+                "[%s] EOP-PROBE open — layer=%s/%s percent=%s remaining=%s",
+                self.serial_number,
+                layer,
+                total,
+                percent,
+                remaining,
+            )
+
+        closing = str(data.get("gcode_state") or "") in _END_OF_PRINT_PROBE_CLOSING_STATES
+        changed = {k: v for k, v in present.items() if self._eop_probe_last.get(k, object()) != v}
+        self._eop_probe_last.update(present)
+
+        if self._eop_probe_frames >= _END_OF_PRINT_PROBE_MAX_FRAMES and not closing:
+            if self._eop_probe_frames == _END_OF_PRINT_PROBE_MAX_FRAMES:
+                self._eop_probe_frames += 1
+                logger.debug(
+                    "[%s] EOP-PROBE frame budget (%s) reached — suppressing until FINISH",
+                    self.serial_number,
+                    _END_OF_PRINT_PROBE_MAX_FRAMES,
+                )
+            return
+
+        if changed or closing:
+            self._eop_probe_frames += 1
+            logger.debug(
+                "[%s] EOP-PROBE %s%s: %s",
+                self.serial_number,
+                self._eop_probe_frames,
+                " CLOSE" if closing else "",
+                # `changed` on a closing frame can be empty; fall back to the
+                # full picture so the last line is always self-contained.
+                changed if changed else present,
+            )
+
+        if closing:
+            self._eop_probe_open = False
+            self._eop_probe_armed = False
+            self._eop_probe_last = {}
+
     def _update_state(self, data: dict):
         """Update printer state from message data."""
         _previous_state = self.state.state
 
+        # #2547: instrumentation only — runs before any state mutation so the
+        # frame carrying an end-of-print signal is logged as it arrived.
+        try:
+            self._probe_end_of_print(data)
+        except Exception:  # pragma: no cover - a probe must never break ingest
+            logger.debug("[%s] EOP-PROBE failed", self.serial_number, exc_info=True)
+
         # Update state fields
         if "gcode_state" in data:
             self.state.state = data["gcode_state"]
@@ -3850,6 +4094,11 @@ class BambuMQTTClient:
             self._completion_triggered = False
             # #1721: rearm the end-of-print finish-photo trigger for the new print
             self._finish_photo_captured = False
+            # #2547: rearm the end-of-print telemetry probe for the new print
+            self._eop_probe_armed = True
+            self._eop_probe_open = False
+            self._eop_probe_frames = 0
+            self._eop_probe_last = {}
             # Reset last valid progress/layer for usage tracking
             self._last_valid_progress = 0.0
             self._last_valid_layer_num = 0

+ 4 - 1
backend/app/services/camera.py

@@ -16,6 +16,8 @@ import uuid
 from datetime import datetime
 from pathlib import Path
 
+from backend.app.core.logging_filters import redact_url_credentials
+
 logger = logging.getLogger(__name__)
 
 # JPEG markers
@@ -608,7 +610,8 @@ async def capture_camera_frame_bytes(
             logger.info("Successfully captured camera frame bytes: %s bytes", len(stdout))
             return stdout
         else:
-            stderr_text = stderr.decode() if stderr else "Unknown error"
+            # ffmpeg echoes the RTSP input URL, which carries the access code.
+            stderr_text = redact_url_credentials(stderr.decode()) if stderr else "Unknown error"
             logger.error("ffmpeg frame bytes capture failed (code %s): %s", process.returncode, stderr_text[:200])
             return None
 

+ 18 - 8
backend/app/services/external_camera.py

@@ -17,6 +17,8 @@ from urllib.parse import urlparse
 
 import aiohttp
 
+from backend.app.core.logging_filters import redact_url_credentials
+
 logger = logging.getLogger(__name__)
 
 
@@ -195,9 +197,15 @@ async def capture_frame(
         JPEG bytes or None on failure
     """
     if snapshot_url:
-        logger.debug("capture_frame using snapshot override url=%s...", snapshot_url[:50])
+        # Redact before truncating — slicing first can cut the URL short of the
+        # ``@`` the pattern anchors on and leave the password in the log.
+        logger.debug("capture_frame using snapshot override url=%s...", redact_url_credentials(snapshot_url)[:50])
         return await _capture_snapshot(snapshot_url, timeout)
-    logger.debug("capture_frame called: type=%s, url=%s...", camera_type, url[:50] if url else "None")
+    logger.debug(
+        "capture_frame called: type=%s, url=%s...",
+        camera_type,
+        redact_url_credentials(url)[:50] if url else "None",
+    )
     if camera_type == "mjpeg":
         return await _capture_mjpeg_frame(url, timeout)
     elif camera_type == "rtsp":
@@ -311,7 +319,7 @@ async def _capture_mjpeg_frame(url: str, timeout: int) -> bytes | None:
     """
     safe_url = _sanitize_camera_url(url, ("http", "https"))
     if not safe_url:
-        logger.error("Invalid MJPEG URL format: %s...", url[:50])
+        logger.error("Invalid MJPEG URL format: %s...", redact_url_credentials(url)[:50])
         return None
 
     jpeg_start = b"\xff\xd8"
@@ -438,7 +446,8 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
         )
 
         if process.returncode != 0:
-            logger.error("ffmpeg RTSP capture failed: %s", stderr.decode()[:200])
+            # ffmpeg echoes the RTSP input URL, which carries the camera password.
+            logger.error("ffmpeg RTSP capture failed: %s", redact_url_credentials(stderr.decode())[:200])
             return None
 
         if not stdout or len(stdout) < 100:
@@ -504,7 +513,7 @@ async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
     # Sanitize URL - returns reconstructed URL from validated components
     safe_url = _sanitize_camera_url(url, ("http", "https"))
     if not safe_url:
-        logger.error("Invalid snapshot URL format: %s...", url[:50])
+        logger.error("Invalid snapshot URL format: %s...", redact_url_credentials(url)[:50])
         return None
 
     try:
@@ -559,7 +568,7 @@ async def test_connection(url: str, camera_type: str) -> dict:
     Returns:
         Dict with {success: bool, error?: str, resolution?: str}
     """
-    logger.info("Testing camera connection: type=%s, url=%s...", camera_type, url[:50])
+    logger.info("Testing camera connection: type=%s, url=%s...", camera_type, redact_url_credentials(url)[:50])
     try:
         frame = await capture_frame(url, camera_type, timeout=10)
         logger.info("Capture result: %s bytes", len(frame) if frame else 0)
@@ -700,7 +709,7 @@ async def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]:
     # Sanitize URL - returns reconstructed URL from validated components
     safe_url = _sanitize_camera_url(url, ("http", "https"))
     if not safe_url:
-        logger.error("Invalid MJPEG stream URL: %s...", url[:50])
+        logger.error("Invalid MJPEG stream URL: %s...", redact_url_credentials(url)[:50])
         return
 
     try:
@@ -837,7 +846,8 @@ async def _stream_rtsp(
         await asyncio.sleep(0.1)
         if process.returncode is not None:
             stderr = await process.stderr.read()
-            logger.error("ffmpeg RTSP stream failed immediately: %s", stderr.decode()[:300])
+            # ffmpeg echoes the RTSP input URL, which carries the camera password.
+            logger.error("ffmpeg RTSP stream failed immediately: %s", redact_url_credentials(stderr.decode())[:300])
             return
 
         buffer = b""

+ 6 - 2
backend/app/services/log_reader.py

@@ -14,6 +14,7 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.config import settings
+from backend.app.core.logging_filters import URL_CREDENTIALS_PATTERN
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
@@ -168,8 +169,11 @@ def sanitize_log_content(content: str, sensitive_strings: dict[str, str] | None
                 continue  # Skip very short strings to prevent over-redaction
             content = re.sub(re.escape(value), label, content)
 
-    # Replace credentials in URLs (e.g. http://user:pass@host, rtsps://bblp:code@host)
-    content = re.sub(r"((?:https?|rtsps?)://)[^/:@\s]+:[^/@\s]+@", r"\1[CREDENTIALS]@", content)
+    # Replace credentials in URLs (e.g. http://user:pass@host, rtsps://bblp:code@host).
+    # Shares its pattern with the log-pipeline redaction in ``core.logging_filters`` so
+    # the two can't drift; the bundle drops the username too, where the live log keeps
+    # it for diagnosis.
+    content = URL_CREDENTIALS_PATTERN.sub(r"\g<scheme>[CREDENTIALS]@", content)
 
     # Replace email addresses
     content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)

+ 34 - 2
backend/app/services/printer_diagnostic.py

@@ -16,6 +16,7 @@ import socket
 
 from backend.app.models.printer import Printer
 from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
+from backend.app.services.bambu_mqtt import CONNECT_ERROR_AUTH_REJECTED
 from backend.app.services.camera import get_camera_port
 from backend.app.services.discovery import is_running_in_docker
 from backend.app.services.printer_manager import printer_manager
@@ -56,6 +57,21 @@ async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT)
         return False
 
 
+def _auth_reason_params(reason: str | None) -> dict:
+    """Map a client's CONNACK-refusal slug onto the check's `params.reason`.
+
+    The frontend renders `diagnostic.check.<id>.<status>_<reason>` when a reason
+    is present and falls back to the plain per-status text otherwise, so an
+    unknown or absent slug degrades to today's generic wording rather than a
+    missing string. Only `auth_rejected` currently carries its own message:
+    that is the one case where the printer positively told us the credentials
+    were wrong, as opposed to us merely observing that we are not connected.
+    """
+    if reason == CONNECT_ERROR_AUTH_REJECTED:
+        return {"reason": CONNECT_ERROR_AUTH_REJECTED}
+    return {}
+
+
 def _camera_port_for_printer(printer: Printer | None) -> tuple[int, str]:
     """Return the model-specific camera diagnostic port and display protocol."""
     if not printer:
@@ -249,14 +265,30 @@ async def run_connection_diagnostic(
                 serial_number=serial_number,
                 access_code=access_code,
             )
-            checks.append(DiagnosticCheck(id="mqtt_auth", status="pass" if result.get("success") else "fail"))
+            checks.append(
+                DiagnosticCheck(
+                    id="mqtt_auth",
+                    status="pass" if result.get("success") else "fail",
+                    params=_auth_reason_params(result.get("reason")),
+                )
+            )
         except Exception:
             logger.debug("test_connection failed during diagnostic", exc_info=True)
             checks.append(DiagnosticCheck(id="mqtt_auth", status="fail"))
     elif state is not None:
         # Existing printer: trust the live MQTT state rather than opening a
         # second connection (Bambu printers tolerate few concurrent sessions).
-        checks.append(DiagnosticCheck(id="mqtt_auth", status="pass" if state.connected else "fail"))
+        # `connected == False` alone does not say *why* — the live client keeps
+        # the last CONNACK refusal, so a rejected access code can be reported as
+        # such instead of as a generic failure the user has to guess at (#2698).
+        client = printer_manager.get_client(printer.id) if printer else None
+        checks.append(
+            DiagnosticCheck(
+                id="mqtt_auth",
+                status="pass" if state.connected else "fail",
+                params={} if state.connected else _auth_reason_params(getattr(client, "last_connect_error", None)),
+            )
+        )
     else:
         checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))
 

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

@@ -951,6 +951,11 @@ class PrinterManager:
                 "success": client.state.connected,
                 "state": client.state.state if client.state.connected else None,
                 "model": client.state.raw_data.get("device_model"),
+                # Why the probe failed, when the printer told us: one of the
+                # CONNECT_ERROR_* slugs, else None. Lets the add-printer flow
+                # and the connection diagnostic say "the printer rejected the
+                # access code" instead of an unqualified failure (#2698).
+                "reason": None if client.state.connected else client.last_connect_error,
             }
         finally:
             # Off-loop teardown — see docstring. paho's loop_stop() joins the

+ 6 - 0
backend/app/services/virtual_printer/mqtt_bridge.py

@@ -659,6 +659,12 @@ class MQTTBridge:
             # paints those empty slots as phantom loaded filaments (#1726).
             # Runs whether or not a prev cache existed — fresh pushalls also
             # carry tray_exist_bits and benefit from the cleanup.
+            # These units carry the RAW firmware ids — this cache is what the
+            # slicer sees, and BambuStudio addresses the A2L's AMS-Lite as the
+            # physical id 16 (it sends `ams_get_rfid {ams_id: 16}` through the
+            # VP), so we must not normalise them to 6 the way Bambuddy's
+            # internal state does. `apply_tray_exist_bits` folds 16 onto the
+            # same bit base internally instead (#2697).
             merged_ams_dict = new_state.get("ams")
             if isinstance(merged_ams_dict, dict):
                 units = merged_ams_dict.get("ams")

+ 9 - 2
backend/tests/integration/test_timelapse_scan_session.py

@@ -100,14 +100,17 @@ async def test_scan_timelapse_attaches_and_persists_via_fresh_session(
 
     # base_name = Path("test_print.gcode.3mf").stem = "test_print.gcode", so this
     # video matches by name (strategy 1). .mp4 → no background conversion task.
+    video_bytes = b"fake-timelapse-video-bytes"
     matched = {
         "name": "test_print.gcode.mp4",
         "path": "/timelapse/test_print.gcode.mp4",
         "is_directory": False,
-        "size": 4096,
+        # Must equal len(video_bytes): the download is checked against the
+        # listing, and the file is re-listed afterwards to confirm the printer
+        # has stopped writing it (#2704).
+        "size": len(video_bytes),
         "mtime": None,
     }
-    video_bytes = b"fake-timelapse-video-bytes"
 
     with (
         patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=[matched])),
@@ -119,6 +122,9 @@ async def test_scan_timelapse_attaches_and_persists_via_fresh_session(
             "backend.app.services.bambu_ftp.download_file_bytes_async",
             AsyncMock(return_value=video_bytes),
         ) as mock_download,
+        # A successful attach now removes the printer's copy (#2704); without
+        # this the endpoint would open a real FTP connection to the fixture IP.
+        patch("backend.app.services.bambu_ftp.delete_archived_timelapse", AsyncMock()) as mock_delete,
     ):
         response = await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/scan")
 
@@ -127,6 +133,7 @@ async def test_scan_timelapse_attaches_and_persists_via_fresh_session(
     assert data["status"] == "attached"
     assert data["filename"] == "test_print.gcode.mp4"
     mock_download.assert_awaited_once()
+    mock_delete.assert_awaited_once()
 
     # The write happened in the route's fresh session; confirm it was committed
     # by re-reading the row on the separate test session.

+ 324 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -5,6 +5,7 @@ These tests focus on timelapse tracking during prints.
 """
 
 import json
+import logging
 import time
 
 import pytest
@@ -6653,3 +6654,326 @@ class TestKProfileResponseDoesNotClobberNozzle:
         mqtt_client.state.nozzles[0].nozzle_diameter = "0.8"
         mqtt_client._process_message({"print": {"nozzle_diameter": "0.4"}})
         assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
+
+
+class TestConnectRefusalReporting:
+    """#2698: a refused CONNACK must leave a trace.
+
+    ``_on_connect``'s failure branch used to be a bare ``connected = False``.
+    A printer refusing our access code then looked exactly like one that was
+    powered off: paho reports the follow-up drop as the generic "Unspecified
+    error", so the support bundle from a 30-second reconnect loop carried no
+    hint of the real cause. Bambu speaks MQTT 3.1.1, whose CONNACK return codes
+    4 and 5 paho maps to reason codes 134 / 135.
+    """
+
+    @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",
+        )
+
+    @staticmethod
+    def _connack(v3_return_code):
+        from paho.mqtt.client import convert_connack_rc_to_reason_code
+
+        return convert_connack_rc_to_reason_code(v3_return_code)
+
+    def test_no_error_recorded_before_any_attempt(self, mqtt_client):
+        assert mqtt_client.last_connect_error is None
+        assert mqtt_client.last_connect_error_name is None
+
+    @pytest.mark.parametrize("v3_rc", [4, 5])
+    def test_credential_refusal_recorded(self, mqtt_client, v3_rc, caplog):
+        with caplog.at_level(logging.WARNING):
+            mqtt_client._on_connect(None, None, None, self._connack(v3_rc))
+
+        assert mqtt_client.state.connected is False
+        assert mqtt_client.last_connect_error == "auth_rejected"
+        assert "refused" in caplog.text.lower()
+        # The remedy has to be in the log — that line is what a maintainer
+        # reads out of a support bundle.
+        assert "access code" in caplog.text.lower()
+        # Never leak the credential itself into a bundle.
+        assert "12345678" not in caplog.text
+
+    def test_non_credential_refusal_recorded_separately(self, mqtt_client, caplog):
+        # CONNACK 3 = server unavailable: a real refusal, but not about creds.
+        with caplog.at_level(logging.WARNING):
+            mqtt_client._on_connect(None, None, None, self._connack(3))
+
+        assert mqtt_client.last_connect_error == "refused"
+        assert "access code" not in caplog.text.lower()
+
+    def test_successful_connect_clears_previous_error(self, mqtt_client):
+        mqtt_client._on_connect(None, None, None, self._connack(5))
+        assert mqtt_client.last_connect_error == "auth_rejected"
+
+        mock_client = type("MockClient", (), {"subscribe": lambda self, topic: (0, 1)})()
+        mqtt_client._on_connect(mock_client, None, None, 0)
+
+        assert mqtt_client.state.connected is True
+        assert mqtt_client.last_connect_error is None
+        assert mqtt_client.last_connect_error_name is None
+
+    def test_disconnect_line_carries_the_refusal(self, mqtt_client, caplog):
+        """The reconnect loop is what fills the log, so it must say why."""
+        mqtt_client._on_connect(None, None, None, self._connack(5))
+        caplog.clear()
+
+        with caplog.at_level(logging.WARNING):
+            mqtt_client._on_disconnect(None, None)
+
+        assert "MQTT disconnected" in caplog.text
+        assert "refused" in caplog.text
+        assert "Not authorized" in caplog.text
+
+    def test_disconnect_line_unchanged_without_a_refusal(self, mqtt_client, caplog):
+        with caplog.at_level(logging.WARNING):
+            mqtt_client._on_disconnect(None, None)
+
+        assert "MQTT disconnected" in caplog.text
+        assert "refused" not in caplog.text
+
+
+class TestEndOfPrintProbe:
+    """Tests for #2547: the end-of-print telemetry probe.
+
+    The probe exists to answer a question no existing support bundle can:
+    what do the stage/action fields do between the last object layer and
+    gcode_state=FINISH? stg_cur=22 was supposed to mark "toolhead parked,
+    before filament unload" (#1721) and fires on no model in the field, and
+    Bambuddy drops every other stage field unread. These tests pin the
+    window's boundaries and the guarantee that instrumentation stays
+    instrumentation — it must never raise into the ingest path.
+    """
+
+    LOGGER = "backend.app.services.bambu_mqtt"
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        client._was_running = True
+        client.state.state = "RUNNING"
+        client.state.total_layers = 100
+        client.state.layer_num = 98
+        client.state.progress = 90.0
+        client.state.remaining_time = 12
+        return client
+
+    def test_silent_when_debug_logging_is_off(self, mqtt_client, caplog):
+        """The probe is a debug tool; at INFO it must cost nothing and say
+        nothing, including for a frame that would otherwise open the window."""
+        with caplog.at_level(logging.INFO, logger=self.LOGGER):
+            mqtt_client._process_message({"print": {"layer_num": 100}})
+
+        assert "EOP-PROBE" not in caplog.text
+        assert mqtt_client._eop_probe_open is False
+
+    def test_does_not_open_mid_print(self, mqtt_client, caplog):
+        with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
+            mqtt_client._process_message({"print": {"layer_num": 99, "mc_percent": 91}})
+
+        assert "EOP-PROBE" not in caplog.text
+        assert mqtt_client._eop_probe_open is False
+
+    def test_opens_on_the_last_layer_frame_itself(self, mqtt_client, caplog):
+        """The frame carrying the signal must be captured, not just the ones
+        after it — so the probe has to read the raw frame rather than state,
+        which _update_state only updates further down the same call."""
+        with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
+            mqtt_client._process_message({"print": {"layer_num": 100, "stg_cur": 0}})
+
+        assert "EOP-PROBE open" in caplog.text
+        assert "'layer_num': 100" in caplog.text
+        assert mqtt_client._eop_probe_open is True
+
+    def test_opens_on_progress_when_the_last_layer_packet_is_missed(self, mqtt_client, caplog):
+        """The layer_num edge is a single transient packet and is dropped
+        intermittently (the reason #1867 needed a second mechanism). Progress
+        has to be able to open the window on its own."""
+        with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
+            mqtt_client._process_message({"print": {"mc_percent": 99}})
+
+        assert "EOP-PROBE open" in caplog.text
+        assert mqtt_client._eop_probe_open is True
+
+    def test_zero_remaining_does_not_open_before_the_print_progresses(self, mqtt_client, caplog):
+        """mc_remaining_time reads 0 during pre-print calibration too, so it
+        only counts once progress is non-zero."""
+        mqtt_client.state.progress = 0.0
+        with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
+            mqtt_client._process_message({"print": {"mc_remaining_time": 0, "mc_percent": 0}})
+
+        assert "EOP-PROBE" not in caplog.text
+
+    def test_does_not_open_when_the_print_never_ran(self, mqtt_client, caplog):
+        """Bambuddy restarted mid-print, or firmware replayed a stale frame."""
+        mqtt_client._was_running = False
+        with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
+            mqtt_client._process_message({"print": {"layer_num": 100}})
+
+        assert "EOP-PROBE" not in caplog.text
+
+    def test_logs_only_changed_fields_after_opening(self, mqtt_client, caplog):
+        """Most probed fields are static across the window; logging all of
+        them every frame would bury the transitions we're looking for."""
+        with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
+            mqtt_client._process_message({"print": {"layer_num": 100, "stg_cur": 0}})
+            caplog.clear()
+            # Identical frame — nothing moved, so nothing to say.
+            mqtt_client._process_message({"print": {"layer_num": 100, "stg_cur": 0}})
+            assert "EOP-PROBE" not in caplog.text
+
+            mqtt_client._process_message({"print": {"layer_num": 100, "stg_cur": 22}})
+
+        assert "'stg_cur': 22" in caplog.text
+        assert "layer_num" not in caplog.text.split("EOP-PROBE")[-1]
+
+    def test_captures_the_fields_bambuddy_does_not_parse(self, mqtt_client, caplog):
+        """The whole point: mc_stage / mc_action / print_real_action are read
+        by nothing else in the codebase, so only the probe can show them."""
+        with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
+            mqtt_client._process_message({"print": {"layer_num": 100}})
+            mqtt_client._process_message(
+                {
+                    "print": {
+                        "mc_stage": 3,
+                        "mc_action": 8,
+                        "print_real_action": 2,
+                        "print_gcode_action": 5,
+                        "stg_cd": 1,
+                        "home_flag": 2231371,
+                        "spd_lvl": 0,
+                    }
+                }
+            )
+
+        for field in ("mc_stage", "mc_action", "print_real_action", "print_gcode_action", "stg_cd", "spd_lvl"):
+            assert field in caplog.text
+
+    def test_closes_on_finish_and_does_not_reopen(self, mqtt_client, caplog):
+        with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
+            mqtt_client._process_message({"print": {"layer_num": 100}})
+            mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
+
+            assert "EOP-PROBE 2 CLOSE" in caplog.text
+            assert mqtt_client._eop_probe_open is False
+            assert mqtt_client._eop_probe_armed is False
+
+            caplog.clear()
+            # Firmware re-sending FINISH, or a stale replay, must not restart it.
+            mqtt_client._process_message({"print": {"layer_num": 100, "mc_percent": 100}})
+
+        assert "EOP-PROBE" not in caplog.text
+
+    def test_closing_frame_is_self_contained_when_nothing_changed(self, mqtt_client, caplog):
+        """A FINISH frame that repeats values already seen still has to log
+        something — otherwise the window has no visible end."""
+        with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
+            mqtt_client._process_message({"print": {"gcode_state": "RUNNING", "mc_percent": 100}})
+            caplog.clear()
+            mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
+            # Same value the probe already recorded on the opening frame.
+            mqtt_client._eop_probe_open = True
+            mqtt_client._eop_probe_armed = True
+            mqtt_client._eop_probe_last = {"gcode_state": "FINISH"}
+            mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
+
+        assert "CLOSE" in caplog.text
+        assert "'gcode_state': 'FINISH'" in caplog.text
+
+    def test_rearms_for_the_next_print(self, mqtt_client, caplog):
+        # A completion callback is what lets _update_state finish the print
+        # (and clear _was_running), which the new-print detection depends on.
+        mqtt_client.on_print_complete = lambda data: None
+        mqtt_client.state.gcode_file = "current.3mf"
+        mqtt_client._previous_gcode_state = "RUNNING"
+
+        with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
+            mqtt_client._process_message({"print": {"layer_num": 100}})
+            mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
+            assert mqtt_client._eop_probe_armed is False
+
+            # New print: RUNNING again with a file, after the previous print
+            # completed. _update_state rearms the probe alongside the
+            # finish-photo one-shot.
+            mqtt_client._process_message(
+                {"print": {"gcode_state": "RUNNING", "gcode_file": "next.3mf", "subtask_name": "next"}}
+            )
+            assert mqtt_client._eop_probe_armed is True
+
+            caplog.clear()
+            mqtt_client.state.total_layers = 50
+            mqtt_client._process_message({"print": {"layer_num": 50}})
+
+        assert "EOP-PROBE open" in caplog.text
+
+    def test_frame_budget_caps_output_but_still_logs_the_close(self, mqtt_client, caplog):
+        """A long final layer holds the window open at ~1 frame/second; the
+        user still has to be able to upload the resulting log."""
+        from backend.app.services.bambu_mqtt import _END_OF_PRINT_PROBE_MAX_FRAMES
+
+        with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
+            mqtt_client._process_message({"print": {"layer_num": 100}})
+            for i in range(_END_OF_PRINT_PROBE_MAX_FRAMES + 50):
+                mqtt_client._process_message({"print": {"mc_remaining_time": i}})
+
+            assert "frame budget" in caplog.text
+            caplog.clear()
+            mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
+
+        assert "CLOSE" in caplog.text
+
+    def test_opens_on_numeric_strings(self, mqtt_client, caplog):
+        """Firmware sends these as ints or as numeric strings depending on
+        model and field, so the window checks must coerce rather than compare
+        a str against an int and silently never open."""
+        with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
+            mqtt_client._process_message({"print": {"layer_num": "100", "mc_percent": "99"}})
+
+        assert "EOP-PROBE open" in caplog.text
+        assert mqtt_client._eop_probe_open is True
+
+    def test_coercion_helper_falls_back_on_junk(self, mqtt_client):
+        """Unit-level, because feeding junk through _process_message would trip
+        the pre-existing parsers before ever reaching the probe. The guarantee
+        under test is only that the probe's own reads can't raise."""
+        assert mqtt_client._probe_number("100") == 100.0
+        assert mqtt_client._probe_number("not-a-number", 7) == 7
+        assert mqtt_client._probe_number(None) is None
+        assert mqtt_client._probe_number({"unexpected": "shape"}, 0) == 0
+
+    def test_probe_failure_cannot_break_ingest(self, mqtt_client, caplog, monkeypatch):
+        """Instrumentation must stay instrumentation: if the probe ever throws,
+        state parsing still has to complete."""
+
+        def boom(_data):
+            raise RuntimeError("probe exploded")
+
+        monkeypatch.setattr(mqtt_client, "_probe_end_of_print", boom)
+
+        with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
+            mqtt_client._process_message({"print": {"gcode_state": "RUNNING", "layer_num": 100}})
+
+        assert mqtt_client.state.layer_num == 100
+        assert "EOP-PROBE failed" in caplog.text
+
+    def test_never_logs_the_access_code(self, mqtt_client, caplog):
+        with caplog.at_level(logging.DEBUG, logger=self.LOGGER):
+            mqtt_client._process_message({"print": {"layer_num": 100}})
+            mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
+
+        probe_lines = [line for line in caplog.text.splitlines() if "EOP-PROBE" in line]
+        assert probe_lines
+        assert not any("12345678" in line for line in probe_lines)

+ 57 - 1
backend/tests/unit/services/test_printer_diagnostic.py

@@ -51,6 +51,7 @@ class _Env:
         state=None,
         test_connection_success=True,
         report_messages_since_connect: int | None = 5,
+        connect_error: str | None = None,
     ):
         self.ports = ports or _port_probe()
         self.in_docker = in_docker
@@ -61,17 +62,26 @@ class _Env:
         # ``None`` means get_client returns None (e.g. pre-add flow); an int
         # means there's a client with that counter value.
         self.report_messages_since_connect = report_messages_since_connect
+        # CONNACK-refusal slug the live client reports, or None when the last
+        # connection attempt was never refused (#2698).
+        self.connect_error = connect_error
         self._stack = ExitStack()
 
     def __enter__(self):
         manager = MagicMock()
         manager.get_status.return_value = self.state
-        manager.test_connection = AsyncMock(return_value={"success": self.test_connection_success})
+        manager.test_connection = AsyncMock(
+            return_value={
+                "success": self.test_connection_success,
+                "reason": None if self.test_connection_success else self.connect_error,
+            }
+        )
         if self.report_messages_since_connect is None:
             manager.get_client.return_value = None
         else:
             client = MagicMock()
             client.report_messages_since_connect = self.report_messages_since_connect
+            client.last_connect_error = self.connect_error
             manager.get_client.return_value = client
         self._stack.enter_context(patch(f"{MOD}._check_port", new_callable=AsyncMock, side_effect=self.ports))
         self._stack.enter_context(patch(f"{MOD}.is_running_in_docker", return_value=self.in_docker))
@@ -248,6 +258,52 @@ class TestExistingPrinter:
         assert params == {}
 
 
+class TestAuthRejectedReason:
+    """#2698: "not connected" and "credentials refused" are different answers.
+
+    `state.connected == False` only says we have no session — the printer may
+    be rebooting, at its connection limit, or refusing the access code. When
+    the printer actually sent a CONNACK refusal the client records it, and the
+    check surfaces it as a `params.reason` variant so the UI can name the cause
+    instead of making the user guess. Without a recorded refusal the params
+    stay empty and the generic text is used.
+    """
+
+    def _params(self, result):
+        return next(c.params for c in result.checks if c.id == "mqtt_auth")
+
+    async def test_recorded_refusal_surfaces_reason(self):
+        with _Env(state=_state(connected=False), connect_error="auth_rejected"):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert _statuses(result)["mqtt_auth"] == "fail"
+        assert self._params(result) == {"reason": "auth_rejected"}
+
+    async def test_disconnected_without_refusal_stays_generic(self):
+        with _Env(state=_state(connected=False)):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert _statuses(result)["mqtt_auth"] == "fail"
+        assert self._params(result) == {}
+
+    async def test_unknown_slug_falls_back_to_generic(self):
+        # `refused` has no dedicated message — degrade to the plain fail text
+        # rather than asking the frontend for a key that doesn't exist.
+        with _Env(state=_state(connected=False), connect_error="refused"):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert self._params(result) == {}
+
+    async def test_connected_printer_carries_no_reason(self):
+        with _Env(state=_state(connected=True), connect_error="auth_rejected"):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert _statuses(result)["mqtt_auth"] == "pass"
+        assert self._params(result) == {}
+
+    async def test_pre_add_probe_surfaces_reason(self):
+        with _Env(test_connection_success=False, connect_error="auth_rejected"):
+            result = await run_connection_diagnostic("192.168.1.50", serial_number="01P", access_code="wrong")
+        assert _statuses(result)["mqtt_auth"] == "fail"
+        assert self._params(result) == {"reason": "auth_rejected"}
+
+
 class TestPreAddFlow:
     async def test_bad_credentials_fail_mqtt_auth(self):
         with _Env(test_connection_success=False):

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

@@ -20,6 +20,7 @@ from backend.app.services.bambu_mqtt import (
     A2L_LITE_PHYSICAL_AMS_ID,
     BambuMQTTClient,
     a2l_lite_wire_ids,
+    apply_tray_exist_bits,
     normalize_am_unit_id,
 )
 
@@ -142,6 +143,71 @@ class TestTrayNowGlobalisation:
         assert client.state.last_loaded_tray == 26
 
 
+class TestTrayExistBitsBitBase:
+    """#2697: ``apply_tray_exist_bits`` is reached with BOTH ids.
+
+    ``_handle_ams_data`` normalises 16 -> 6 before calling it, but the VP
+    bridge parses the raw printer payload itself and still holds the physical
+    16. Reading 16 as ``16 * 4`` lands on bits 64-67, where nothing is ever
+    set, so every A2L slot was wiped in the slicer-facing cache. Both ids must
+    resolve to bit base 24.
+    """
+
+    # Reporter's capture: bits 24, 25, 26 set -> slots 0/1/2 loaded, slot 3 empty.
+    BITS = "7000000"
+
+    def _units(self, ams_id):
+        return [
+            {
+                "id": ams_id,
+                "tray": [
+                    {
+                        "id": str(i),
+                        "state": 3,
+                        "tray_type": "PLA",
+                        "tray_color": "C12E1FFF",
+                        "tray_info_idx": "GFA00",
+                        "remain": 100,
+                    }
+                    for i in range(4)
+                ],
+            }
+        ]
+
+    def test_physical_id_16_uses_bit_base_24(self):
+        units = self._units(A2L_LITE_PHYSICAL_AMS_ID)
+        cleared = apply_tray_exist_bits(units, self.BITS)
+        trays = units[0]["tray"]
+        # Slots 0-2 are loaded and must survive untouched.
+        for slot in range(3):
+            assert trays[slot]["tray_type"] == "PLA", f"slot {slot} wrongly cleared"
+            assert trays[slot]["state"] == 3
+        # Only the genuinely empty slot 3 is cleared.
+        assert cleared == 1
+        assert trays[3]["state"] == 9
+        assert trays[3]["tray_type"] == ""
+
+    def test_normalised_id_6_matches_physical_id_16(self):
+        physical = self._units(A2L_LITE_PHYSICAL_AMS_ID)
+        normalised = self._units(A2L_LITE_NORMALIZED_AMS_ID)
+        apply_tray_exist_bits(physical, self.BITS)
+        apply_tray_exist_bits(normalised, self.BITS)
+        assert physical[0]["tray"] == normalised[0]["tray"]
+
+    def test_exists_annotation_matches_physical_slots(self):
+        units = self._units(A2L_LITE_PHYSICAL_AMS_ID)
+        apply_tray_exist_bits(units, self.BITS, annotate_exists=True)
+        assert [t["exists"] for t in units[0]["tray"]] == [True, True, True, False]
+
+    def test_regular_ams_unchanged(self):
+        # id 0 still reads bits 0-3 — the fold must not touch any other unit.
+        units = self._units(0)
+        apply_tray_exist_bits(units, "e")  # bits 1,2,3
+        trays = units[0]["tray"]
+        assert trays[0]["state"] == 9
+        assert [t["tray_type"] for t in trays] == ["", "PLA", "PLA", "PLA"]
+
+
 class TestOutboundTranslation:
     def test_set_filament_setting_uses_physical_16_local_slot(self):
         client = _wired(_client())

+ 68 - 17
backend/tests/unit/test_archive_filtering.py

@@ -259,6 +259,9 @@ class TestScanForTimelapseWithRetries:
         mock_archive.timelapse_path = timelapse_path
         mock_archive.printer_id = 1
         mock_archive.filename = archive_filename
+        # No persisted print-start baseline (#2704) — these cases exercise the
+        # in-memory / fallback baseline paths.
+        mock_archive.timelapse_baseline = None
 
         mock_printer = MagicMock()
         mock_printer.id = 1
@@ -273,8 +276,13 @@ class TestScanForTimelapseWithRetries:
         mock_session = AsyncMock()
         mock_session.__aenter__ = AsyncMock(return_value=mock_session)
         mock_session.__aexit__ = AsyncMock()
+        # Serves both the printer lookup and the "already claimed by another
+        # archive" query the candidate filter runs (#2704).
         mock_session.execute = AsyncMock(
-            return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=mock_printer))
+            return_value=MagicMock(
+                scalar_one_or_none=MagicMock(return_value=mock_printer),
+                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
+            )
         )
         return mock_session
 
@@ -311,9 +319,14 @@ class TestScanForTimelapseWithRetries:
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
             patch("backend.app.main.ArchiveService", return_value=mock_service),
             patch(f"{_FTP_MODULE}.download_file_bytes_async", new_callable=AsyncMock) as mock_download,
+            # The attach re-lists the file to confirm the printer has finished
+            # writing it (#2704); without this it opens a real FTP connection.
+            patch(f"{_FTP_MODULE}.remote_file_settled", new_callable=AsyncMock) as mock_settled,
+            patch(f"{_FTP_MODULE}.delete_archived_timelapse", new_callable=AsyncMock),
         ):
+            mock_settled.return_value = True
             mock_ws.send_archive_updated = AsyncMock()
-            mock_download.return_value = b"fake video data"
+            mock_download.return_value = b"x" * 2000  # must match the listed size (#2704)
 
             from backend.app.main import _scan_for_timelapse_with_retries
 
@@ -351,9 +364,14 @@ class TestScanForTimelapseWithRetries:
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
             patch("backend.app.main.ArchiveService", return_value=mock_service),
             patch(f"{_FTP_MODULE}.download_file_bytes_async", new_callable=AsyncMock) as mock_download,
+            # The attach re-lists the file to confirm the printer has finished
+            # writing it (#2704); without this it opens a real FTP connection.
+            patch(f"{_FTP_MODULE}.remote_file_settled", new_callable=AsyncMock) as mock_settled,
+            patch(f"{_FTP_MODULE}.delete_archived_timelapse", new_callable=AsyncMock),
         ):
+            mock_settled.return_value = True
             mock_ws.send_archive_updated = AsyncMock()
-            mock_download.return_value = b"fake video data"
+            mock_download.return_value = b"x" * 2000  # must match the listed size (#2704)
 
             from backend.app.main import _scan_for_timelapse_with_retries
 
@@ -363,8 +381,14 @@ class TestScanForTimelapseWithRetries:
         mock_service.attach_timelapse.assert_not_called()
 
     @pytest.mark.asyncio
-    async def test_name_match_fallback(self):
-        """When no new file appears, should fall back to name matching."""
+    async def test_no_name_match_rescue(self):
+        """The name-match fallback was removed (#2704).
+
+        It looked for the print name inside the video filename, but Bambu
+        firmware only ever writes "video_<timestamp>" — across 247 support
+        bundles it ran 159 times and matched zero times. A file already present
+        at baseline belongs to an earlier print, and guessing otherwise from its
+        name attaches the wrong video."""
         mock_archive, mock_printer = self._make_mocks()
 
         baseline_files = [
@@ -392,18 +416,22 @@ class TestScanForTimelapseWithRetries:
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
             patch("backend.app.main.ArchiveService", return_value=mock_service),
             patch(f"{_FTP_MODULE}.download_file_bytes_async", new_callable=AsyncMock) as mock_download,
+            # The attach re-lists the file to confirm the printer has finished
+            # writing it (#2704); without this it opens a real FTP connection.
+            patch(f"{_FTP_MODULE}.remote_file_settled", new_callable=AsyncMock) as mock_settled,
+            patch(f"{_FTP_MODULE}.delete_archived_timelapse", new_callable=AsyncMock),
         ):
+            mock_settled.return_value = True
             mock_ws.send_archive_updated = AsyncMock()
-            mock_download.return_value = b"fake video data"
+            mock_download.return_value = b"x" * 2000  # must match the listed size (#2704)
 
             from backend.app.main import _scan_for_timelapse_with_retries
 
             await _scan_for_timelapse_with_retries(1)
 
-        # Name-match fallback: "benchy" is in "benchy_20240101.mp4"
-        mock_service.attach_timelapse.assert_called_once()
-        attached_filename = mock_service.attach_timelapse.call_args[0][2]
-        assert attached_filename == "benchy_20240101.mp4"
+        # "benchy" is in "benchy_20240101.mp4", but that file was there before
+        # the print started, so it is not this print's video.
+        mock_service.attach_timelapse.assert_not_called()
 
     @pytest.mark.asyncio
     async def test_stops_when_archive_already_has_timelapse(self):
@@ -455,8 +483,14 @@ class TestScanForTimelapseWithRetries:
         mock_sleep.assert_not_called()
 
     @pytest.mark.asyncio
-    async def test_retries_four_times(self):
-        """Should retry with delays [5, 10, 20, 30]."""
+    async def test_polls_until_the_budget_runs_out(self):
+        """The fixed [5, 10, 20, 30] ladder gave up after ~65s (#2704).
+
+        Support bundles showed the attempt that found the video was #1 272
+        times and then 17 / 13 / 13 — flat against the cutoff, i.e. files were
+        still arriving when the old budget expired. It is now a poll: one short
+        first look, then a steady interval until the wall-clock budget or the
+        derived round cap is reached, whichever comes first."""
         mock_archive, mock_printer = self._make_mocks(archive_filename="test.gcode.3mf")
 
         # Never find any files
@@ -480,10 +514,18 @@ class TestScanForTimelapseWithRetries:
 
             await _scan_for_timelapse_with_retries(1)
 
-        # Should have slept 4 times with delays [5, 10, 20, 30]
-        assert mock_sleep.call_count == 4
+        from backend.app.main import (
+            _TIMELAPSE_SCAN_FIRST_DELAY_SECONDS,
+            _TIMELAPSE_SCAN_POLL_INTERVAL_SECONDS,
+            _timelapse_scan_max_attempts,
+        )
+
         sleep_args = [call.args[0] for call in mock_sleep.call_args_list]
-        assert sleep_args == [5, 10, 20, 30]
+        assert len(sleep_args) == _timelapse_scan_max_attempts()
+        assert sleep_args[0] == _TIMELAPSE_SCAN_FIRST_DELAY_SECONDS
+        assert set(sleep_args[1:]) == {_TIMELAPSE_SCAN_POLL_INTERVAL_SECONDS}
+        # Substantially longer than the ladder it replaced.
+        assert sum(sleep_args) > 300
 
 
 class TestListTimelapseVideosAvi:
@@ -546,6 +588,7 @@ class TestListTimelapseVideosAvi:
         mock_archive.timelapse_path = None
         mock_archive.printer_id = 1
         mock_archive.filename = "benchy.gcode.3mf"
+        mock_archive.timelapse_baseline = None
 
         mock_printer = MagicMock()
         mock_printer.id = 1
@@ -580,7 +623,10 @@ class TestListTimelapseVideosAvi:
         mock_session.__aenter__ = AsyncMock(return_value=mock_session)
         mock_session.__aexit__ = AsyncMock()
         mock_session.execute = AsyncMock(
-            return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=mock_printer))
+            return_value=MagicMock(
+                scalar_one_or_none=MagicMock(return_value=mock_printer),
+                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
+            )
         )
 
         with (
@@ -590,9 +636,14 @@ class TestListTimelapseVideosAvi:
             patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
             patch("backend.app.main.ArchiveService", return_value=mock_service),
             patch(f"{_FTP_MODULE}.download_file_bytes_async", new_callable=AsyncMock) as mock_download,
+            # The attach re-lists the file to confirm the printer has finished
+            # writing it (#2704); without this it opens a real FTP connection.
+            patch(f"{_FTP_MODULE}.remote_file_settled", new_callable=AsyncMock) as mock_settled,
+            patch(f"{_FTP_MODULE}.delete_archived_timelapse", new_callable=AsyncMock),
         ):
+            mock_settled.return_value = True
             mock_ws.send_archive_updated = AsyncMock()
-            mock_download.return_value = b"fake avi data"
+            mock_download.return_value = b"x" * 50000  # must match the listed size (#2704)
 
             from backend.app.main import _scan_for_timelapse_with_retries
 

+ 11 - 4
backend/tests/unit/test_finish_photo_from_timelapse.py

@@ -62,11 +62,14 @@ def patched_session(fake_archive, monkeypatch):
 async def test_returns_none_when_timelapse_never_lands(tmp_path: Path, patched_session):
     """Print finished without a timelapse — bail after timeout so the caller
     falls back to the live-camera grab."""
-    result = await _capture_finish_photo_from_timelapse(
+    result, pending = await _capture_finish_photo_from_timelapse(
         archive_id=42,
         archive_dir=tmp_path,
     )
     assert result is None
+    # Ran out of time rather than concluded: the video may still be on its way,
+    # which is what tells the caller to schedule a background upgrade (#2704).
+    assert pending is True
 
 
 async def test_extracts_frame_when_timelapse_lands(tmp_path: Path, patched_session, monkeypatch):
@@ -96,7 +99,7 @@ async def test_extracts_frame_when_timelapse_lands(tmp_path: Path, patched_sessi
         "backend.app.services.camera.extract_video_last_frame",
         new=fake_extract,
     ):
-        result = await _capture_finish_photo_from_timelapse(
+        result, pending = await _capture_finish_photo_from_timelapse(
             archive_id=42,
             archive_dir=tmp_path / "archive_dir",
         )
@@ -105,6 +108,7 @@ async def test_extracts_frame_when_timelapse_lands(tmp_path: Path, patched_sessi
     assert result.startswith("finish_")
     assert result.endswith(".jpg")
     assert (tmp_path / "archive_dir" / "photos" / result).exists()
+    assert pending is False
 
 
 async def test_returns_none_when_extraction_fails(tmp_path: Path, patched_session, monkeypatch):
@@ -125,12 +129,15 @@ async def test_returns_none_when_extraction_fails(tmp_path: Path, patched_sessio
         "backend.app.services.camera.extract_video_last_frame",
         new=fake_extract_fails,
     ):
-        result = await _capture_finish_photo_from_timelapse(
+        result, pending = await _capture_finish_photo_from_timelapse(
             archive_id=42,
             archive_dir=tmp_path / "archive_dir",
         )
 
     assert result is None
+    # The video arrived and ffmpeg refused it — waiting longer cannot help, so
+    # this must NOT ask for a background retry.
+    assert pending is False
 
 
 async def test_polls_until_file_appears(tmp_path: Path, patched_session, monkeypatch):
@@ -163,7 +170,7 @@ async def test_polls_until_file_appears(tmp_path: Path, patched_session, monkeyp
             "backend.app.services.camera.extract_video_last_frame",
             new=fake_extract,
         ):
-            result = await _capture_finish_photo_from_timelapse(
+            result, pending = await _capture_finish_photo_from_timelapse(
                 archive_id=42,
                 archive_dir=tmp_path / "archive_dir",
             )

+ 112 - 0
backend/tests/unit/test_log_credential_redaction.py

@@ -0,0 +1,112 @@
+"""Credentials must never reach bambuddy.log.
+
+Subprocesses echo their input URL back at us: ffmpeg prints the RTSP input in
+its ``Input #0`` line, so logging its stderr verbatim published the printer
+access code (or an external camera's password) into the log file — which users
+routinely attach to public GitHub issues.
+
+These cover the shared helper plus the two funnels that carry subprocess output
+into the log.
+"""
+
+import asyncio
+
+from backend.app.api.routes.camera import _read_ffmpeg_stderr, _summarize_ffmpeg_stderr
+from backend.app.core.logging_filters import redact_url_credentials
+from backend.app.services.log_reader import sanitize_log_content
+
+# What ffmpeg actually prints for the camera's local TLS-proxy input. The
+# access code sits in the userinfo of the URL it quotes back.
+FFMPEG_INPUT_LINE = "Input #0, rtsp, from 'rtsp://bblp:38A4KQ2P@127.0.0.1:48521/streaming/live/1':"
+
+
+class TestRedactUrlCredentials:
+    def test_masks_the_printer_access_code(self):
+        result = redact_url_credentials(FFMPEG_INPUT_LINE)
+        assert "38A4KQ2P" not in result
+        assert result == "Input #0, rtsp, from 'rtsp://bblp:[REDACTED]@127.0.0.1:48521/streaming/live/1':"
+
+    def test_keeps_everything_that_is_not_the_secret(self):
+        """Host, port, path and username stay — the line has to remain diagnosable."""
+        result = redact_url_credentials("rtsp://admin:hunter2@192.168.1.50:554/stream1")
+        assert result == "rtsp://admin:[REDACTED]@192.168.1.50:554/stream1"
+
+    def test_masks_every_scheme_not_just_the_ones_we_use_today(self):
+        for url, expected in (
+            ("http://user:pw@cam.local/snapshot", "http://user:[REDACTED]@cam.local/snapshot"),
+            ("https://user:pw@cam.local/snapshot", "https://user:[REDACTED]@cam.local/snapshot"),
+            ("rtsps://bblp:code@printer:322/streaming/live/1", "rtsps://bblp:[REDACTED]@printer:322/streaming/live/1"),
+            ("ftp://bblp:code@printer:990/", "ftp://bblp:[REDACTED]@printer:990/"),
+        ):
+            assert redact_url_credentials(url) == expected
+
+    def test_masks_a_password_containing_an_at_sign(self):
+        """The userinfo ends at the LAST @ before the path — no tail may survive."""
+        result = redact_url_credentials("rtsp://admin:p@ssw0rd@192.168.1.50/stream")
+        assert result == "rtsp://admin:[REDACTED]@192.168.1.50/stream"
+        assert "ssw0rd" not in result
+
+    def test_masks_several_urls_in_one_blob(self):
+        text = "first rtsp://bblp:AAAAAAAA@10.0.0.1/live then rtsp://bblp:BBBBBBBB@10.0.0.2/live"
+        result = redact_url_credentials(text)
+        assert "AAAAAAAA" not in result
+        assert "BBBBBBBB" not in result
+        assert result.count("[REDACTED]") == 2
+
+    def test_never_runs_past_the_authority_into_the_path(self):
+        """A later @ in the path must not drag the host into the mask."""
+        result = redact_url_credentials("rtsp://bblp:code@10.0.0.1/live/user@example")
+        assert result == "rtsp://bblp:[REDACTED]@10.0.0.1/live/user@example"
+
+    def test_leaves_credential_free_text_alone(self):
+        for untouched in (
+            "Connection refused",
+            "rtsp://10.0.0.1:554/stream1",
+            "mailto and user@example.com in prose",
+            "Starting USB camera stream from /dev/video0 at 10 fps",
+        ):
+            assert redact_url_credentials(untouched) == untouched
+
+    def test_tolerates_empty_and_none(self):
+        assert redact_url_credentials("") == ""
+        assert redact_url_credentials(None) is None
+
+
+class TestFfmpegStderrFunnel:
+    """`_summarize_ffmpeg_stderr` is the one funnel every stderr log in the
+    camera route passes through, so redaction lands there."""
+
+    def test_summary_strips_the_access_code(self):
+        stderr = f"{FFMPEG_INPUT_LINE}\n[rtsp @ 0x5] Could not find codec parameters\n"
+        result = _summarize_ffmpeg_stderr(stderr)
+        assert "38A4KQ2P" not in result
+        assert "[REDACTED]" in result
+        # The actionable error is untouched.
+        assert "Could not find codec parameters" in result
+
+    def test_incremental_reader_strips_the_access_code(self):
+        async def run():
+            reader = asyncio.StreamReader()
+            reader.feed_data(f"{FFMPEG_INPUT_LINE}\nError opening input: Connection refused\n".encode())
+            reader.feed_eof()
+
+            class _FakeProcess:
+                stderr = reader
+
+            return await _read_ffmpeg_stderr(_FakeProcess())
+
+        result = asyncio.run(run())
+        assert result is not None
+        assert "38A4KQ2P" not in result
+        assert "Connection refused" in result
+
+
+class TestSupportBundleSanitizerUnchanged:
+    """The bundle sanitizer shares the pattern but keeps its own, stricter
+    replacement — it drops the username too. Guard against drift."""
+
+    def test_bundle_still_drops_the_whole_userinfo(self):
+        result = sanitize_log_content("rtsp://bblp:38A4KQ2P@10.0.0.1/live")
+        assert "38A4KQ2P" not in result
+        assert "bblp" not in result
+        assert "[CREDENTIALS]@" in result

+ 767 - 0
backend/tests/unit/test_timelapse_scan_2704.py

@@ -0,0 +1,767 @@
+"""Timelapse scan reliability (#2704).
+
+A Bambu printer in LAN-only mode never reaches Bambu's NTP server, so the clock
+behind both the timelapse filename and the FTP mtime drifts freely — the P1S in
+the report was six and a half days out. That is why the automatic scan works by
+diffing the printer's ``/timelapse`` listing against a snapshot taken when the
+print started, and why nothing in that path may fall back to comparing times.
+
+These tests pin the parts that make the diff dependable:
+
+* the candidate is chosen by exclusion, never by ordering (ordering could only
+  be done on the printer's clock);
+* a download that comes up short never attaches and never triggers a delete —
+  deleting the printer's copy is only safe because the transfer was verified;
+* the baseline persisted at print start is what the manual Scan button uses,
+  instead of the clock-based strategies that cannot work on a drifted printer.
+"""
+
+import logging
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+logger = logging.getLogger(__name__)
+
+
+def _printer():
+    p = MagicMock()
+    p.id = 1
+    p.name = "TestP1S"
+    p.ip_address = "192.168.1.100"
+    p.access_code = "12345678"
+    p.model = "P1S"
+    return p
+
+
+def _video(name: str, size: int = 1000):
+    return {"name": name, "is_directory": False, "path": f"/timelapse/{name}", "size": size}
+
+
+def _session(archive=None):
+    session = AsyncMock()
+    session.__aenter__ = AsyncMock(return_value=session)
+    session.__aexit__ = AsyncMock()
+    if archive is not None:
+        session.get = AsyncMock(return_value=archive)
+    return session
+
+
+class TestCandidateSelection:
+    """Which of the printer's videos belongs to this print."""
+
+    @pytest.fixture
+    def attach(self):
+        from backend.app.main import _attach_first_unclaimed_timelapse
+
+        return _attach_first_unclaimed_timelapse
+
+    @pytest.mark.asyncio
+    async def test_nothing_new_since_baseline_is_not_an_attach(self, attach):
+        result = await attach(
+            42,
+            _printer(),
+            [_video("video_2026-07-21_09-17-37.avi")],
+            {"video_2026-07-21_09-17-37.avi"},
+            set(),
+            1,
+            logger,
+        )
+        assert result is False
+
+    @pytest.mark.asyncio
+    async def test_attaches_the_one_new_file_and_deletes_it_from_the_printer(self, attach):
+        download = AsyncMock(return_value=b"x" * 1000)
+        delete = AsyncMock(return_value=True)
+        service = MagicMock()
+        service.attach_timelapse = AsyncMock(return_value=True)
+
+        with (
+            patch("backend.app.services.bambu_ftp.download_file_bytes_async", download),
+            patch("backend.app.services.bambu_ftp.remote_file_settled", AsyncMock(return_value=True)),
+            patch("backend.app.services.bambu_ftp.delete_archived_timelapse", delete),
+            patch("backend.app.main.async_session", return_value=_session()),
+            patch("backend.app.main.ArchiveService", return_value=service),
+            patch("backend.app.main.ws_manager", MagicMock(send_archive_updated=AsyncMock())),
+        ):
+            result = await attach(
+                42,
+                _printer(),
+                [_video("old.avi"), _video("video_2026-07-22_06-18-39.avi")],
+                {"old.avi"},
+                set(),
+                1,
+                logger,
+            )
+
+        assert result is True
+        service.attach_timelapse.assert_awaited_once()
+        assert service.attach_timelapse.await_args.args[2] == "video_2026-07-22_06-18-39.avi"
+        delete.assert_awaited_once()
+        assert delete.await_args.args[2] == "/timelapse/video_2026-07-22_06-18-39.avi"
+
+    @pytest.mark.asyncio
+    async def test_skips_a_previous_prints_late_landing_video(self, attach):
+        """Two files are new since the baseline because the previous print's
+        video only landed after this print started. It is already attached to
+        another archive, so it is excluded by name — no timestamps involved."""
+        download = AsyncMock(return_value=b"y" * 1000)
+        service = MagicMock()
+        service.attach_timelapse = AsyncMock(return_value=True)
+
+        with (
+            patch("backend.app.services.bambu_ftp.download_file_bytes_async", download),
+            patch("backend.app.services.bambu_ftp.remote_file_settled", AsyncMock(return_value=True)),
+            patch("backend.app.services.bambu_ftp.delete_archived_timelapse", AsyncMock()),
+            patch("backend.app.main.async_session", return_value=_session()),
+            patch("backend.app.main.ArchiveService", return_value=service),
+            patch("backend.app.main.ws_manager", MagicMock(send_archive_updated=AsyncMock())),
+        ):
+            result = await attach(
+                42,
+                _printer(),
+                # Listing order puts the previous print's video first, so a
+                # naive "take the first new one" would grab the wrong video.
+                [_video("previous_print.avi"), _video("this_print.avi")],
+                set(),
+                {"previous_print"},
+                1,
+                logger,
+            )
+
+        assert result is True
+        assert service.attach_timelapse.await_args.args[2] == "this_print.avi"
+
+    @pytest.mark.asyncio
+    async def test_claimed_match_survives_the_mp4_conversion(self, attach):
+        """Attached AVIs are converted to MP4 afterwards, which keeps the stem
+        but changes the extension — so exclusion has to compare stems."""
+        result = await attach(
+            42,
+            _printer(),
+            [_video("video_2026-07-22_06-18-39.avi")],
+            set(),
+            {"video_2026-07-22_06-18-39"},  # stored as .mp4 on the archive
+            1,
+            logger,
+        )
+        assert result is False
+
+    @pytest.mark.asyncio
+    async def test_all_new_files_claimed_keeps_polling(self, attach):
+        result = await attach(42, _printer(), [_video("a.avi"), _video("b.avi")], set(), {"a", "b"}, 1, logger)
+        assert result is False
+
+
+class TestDownloadVerificationGatesTheDelete:
+    """The printer's copy is the only other copy — it goes only after the
+    transfer is verified against the size the listing reported."""
+
+    @pytest.fixture
+    def attach(self):
+        from backend.app.main import _attach_first_unclaimed_timelapse
+
+        return _attach_first_unclaimed_timelapse
+
+    @pytest.mark.asyncio
+    async def test_passes_the_listed_size_to_the_downloader(self, attach):
+        download = AsyncMock(return_value=b"z" * 4096)
+        service = MagicMock()
+        service.attach_timelapse = AsyncMock(return_value=True)
+
+        with (
+            patch("backend.app.services.bambu_ftp.download_file_bytes_async", download),
+            patch("backend.app.services.bambu_ftp.remote_file_settled", AsyncMock(return_value=True)),
+            patch("backend.app.services.bambu_ftp.delete_archived_timelapse", AsyncMock()),
+            patch("backend.app.main.async_session", return_value=_session()),
+            patch("backend.app.main.ArchiveService", return_value=service),
+            patch("backend.app.main.ws_manager", MagicMock(send_archive_updated=AsyncMock())),
+        ):
+            await attach(42, _printer(), [_video("new.avi", size=4096)], set(), set(), 1, logger)
+
+        assert download.await_args.kwargs["expected_size"] == 4096
+
+    @pytest.mark.asyncio
+    async def test_short_download_does_not_attach_or_delete(self, attach):
+        """download_file_bytes_async returns None on a size mismatch. The
+        printer must keep its copy so the next poll round can retry."""
+        delete = AsyncMock()
+        service = MagicMock()
+        service.attach_timelapse = AsyncMock(return_value=True)
+
+        with (
+            patch("backend.app.services.bambu_ftp.download_file_bytes_async", AsyncMock(return_value=None)),
+            patch("backend.app.services.bambu_ftp.remote_file_settled", AsyncMock(return_value=True)),
+            patch("backend.app.services.bambu_ftp.delete_archived_timelapse", delete),
+            patch("backend.app.main.async_session", return_value=_session()),
+            patch("backend.app.main.ArchiveService", return_value=service),
+        ):
+            result = await attach(42, _printer(), [_video("new.avi")], set(), set(), 1, logger)
+
+        assert result is False
+        service.attach_timelapse.assert_not_awaited()
+        delete.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_failed_attach_does_not_delete(self, attach):
+        delete = AsyncMock()
+        service = MagicMock()
+        service.attach_timelapse = AsyncMock(return_value=False)
+
+        with (
+            patch("backend.app.services.bambu_ftp.download_file_bytes_async", AsyncMock(return_value=b"x" * 1000)),
+            patch("backend.app.services.bambu_ftp.remote_file_settled", AsyncMock(return_value=True)),
+            patch("backend.app.services.bambu_ftp.delete_archived_timelapse", delete),
+            patch("backend.app.main.async_session", return_value=_session()),
+            patch("backend.app.main.ArchiveService", return_value=service),
+        ):
+            result = await attach(42, _printer(), [_video("new.avi")], set(), set(), 1, logger)
+
+        assert result is False
+        delete.assert_not_awaited()
+
+
+class TestFtpDownloadSizeCheck:
+    """`download_file` is where a truncated FTPS transfer used to pass for a
+    complete one — a partial buffer is non-empty, so every caller downstream
+    treated it as a good file."""
+
+    def _client(self, payload: bytes):
+        from backend.app.services.bambu_ftp import BambuFTPClient
+
+        client = BambuFTPClient("192.168.1.100", "12345678")
+        ftp = MagicMock()
+        ftp.retrbinary = MagicMock(side_effect=lambda cmd, cb: cb(payload))
+        client._ftp = ftp
+        return client
+
+    def test_exact_size_passes(self):
+        assert self._client(b"a" * 500).download_file("/timelapse/v.avi", expected_size=500) == b"a" * 500
+
+    def test_short_read_is_a_failure(self):
+        assert self._client(b"a" * 499).download_file("/timelapse/v.avi", expected_size=500) is None
+
+    def test_long_read_is_a_failure(self):
+        """Not expected in practice, but a mismatch either way means we don't
+        know what we have, and we're about to delete the original."""
+        assert self._client(b"a" * 501).download_file("/timelapse/v.avi", expected_size=500) is None
+
+    def test_zero_bytes_is_a_failure_even_without_an_expected_size(self):
+        assert self._client(b"").download_file("/cache/whatever.3mf") is None
+
+    def test_unverified_download_still_works_for_callers_that_do_not_pass_a_size(self):
+        assert self._client(b"abc").download_file("/cache/whatever.3mf") == b"abc"
+
+
+class TestDeleteIsBestEffort:
+    """A printer that refuses the delete must not break the flow — the video
+    is already in the archive, and the diff excludes it by name from then on."""
+
+    @pytest.mark.asyncio
+    async def test_reports_success_on_delete(self):
+        from backend.app.services.bambu_ftp import DeleteResult, delete_archived_timelapse
+
+        with patch(
+            "backend.app.services.bambu_ftp.delete_file_async", AsyncMock(return_value=DeleteResult.DELETED)
+        ) as d:
+            assert await delete_archived_timelapse("1.2.3.4", "code", "/timelapse/v.avi", verified=True) is True
+        assert d.await_count == 1
+
+    @pytest.mark.asyncio
+    async def test_not_found_is_success_and_is_not_retried(self):
+        """550 means the printer already cleaned up; waiting cannot change it."""
+        from backend.app.services.bambu_ftp import DeleteResult, delete_archived_timelapse
+
+        with patch(
+            "backend.app.services.bambu_ftp.delete_file_async", AsyncMock(return_value=DeleteResult.NOT_FOUND)
+        ) as d:
+            assert await delete_archived_timelapse("1.2.3.4", "code", "/timelapse/v.avi", verified=True) is True
+        assert d.await_count == 1
+
+    @pytest.mark.asyncio
+    async def test_failure_retries_then_gives_up_without_raising(self):
+        from backend.app.services.bambu_ftp import DeleteResult, delete_archived_timelapse
+
+        with (
+            patch("backend.app.services.bambu_ftp.delete_file_async", AsyncMock(return_value=DeleteResult.FAILED)),
+            patch("backend.app.services.bambu_ftp.asyncio.sleep", AsyncMock()),
+        ):
+            assert await delete_archived_timelapse("1.2.3.4", "code", "/timelapse/v.avi", verified=True) is False
+
+    @pytest.mark.asyncio
+    async def test_raising_transport_does_not_propagate(self):
+        from backend.app.services.bambu_ftp import delete_archived_timelapse
+
+        with (
+            patch("backend.app.services.bambu_ftp.delete_file_async", AsyncMock(side_effect=OSError("boom"))),
+            patch("backend.app.services.bambu_ftp.asyncio.sleep", AsyncMock()),
+        ):
+            assert await delete_archived_timelapse("1.2.3.4", "code", "/timelapse/v.avi", verified=True) is False
+
+
+class TestBaselineIsPersisted:
+    """The baseline has to outlive the process: a restart mid-print used to
+    lose it, and the manual scan never had access to it at all."""
+
+    @pytest.mark.asyncio
+    async def test_written_to_the_archive_row_at_print_start(self):
+        from backend.app.main import _capture_timelapse_baseline_at_start
+
+        archive = MagicMock()
+        archive.timelapse_baseline = None
+        session = _session(archive)
+
+        with (
+            patch("backend.app.main.async_session", return_value=session),
+            patch(
+                "backend.app.main._list_timelapse_videos",
+                new=AsyncMock(return_value=([_video("a.avi"), _video("b.avi")], "/timelapse")),
+            ),
+        ):
+            await _capture_timelapse_baseline_at_start(_printer(), 1, logger, archive_id=7)
+
+        assert archive.timelapse_baseline == ["a.avi", "b.avi"]
+        session.commit.assert_awaited()
+
+    @pytest.mark.asyncio
+    async def test_no_archive_id_keeps_it_in_memory_only(self):
+        from backend.app.main import _capture_timelapse_baseline_at_start, _timelapse_baselines
+
+        _timelapse_baselines.pop(1, None)
+        session = _session(MagicMock())
+
+        with (
+            patch("backend.app.main.async_session", return_value=session),
+            patch(
+                "backend.app.main._list_timelapse_videos",
+                new=AsyncMock(return_value=([_video("a.avi")], "/timelapse")),
+            ),
+        ):
+            await _capture_timelapse_baseline_at_start(_printer(), 1, logger)
+
+        assert _timelapse_baselines[1] == {"a.avi"}
+        session.commit.assert_not_awaited()
+        _timelapse_baselines.pop(1, None)
+
+    @pytest.mark.asyncio
+    async def test_listing_failure_stores_null_not_an_empty_baseline(self):
+        """An empty list would make every video on the printer look new; NULL
+        correctly means "no baseline" and falls back to a fresh snapshot."""
+        from backend.app.main import _capture_timelapse_baseline_at_start
+
+        archive = MagicMock()
+        session = _session(archive)
+
+        with (
+            patch("backend.app.main.async_session", return_value=session),
+            patch("backend.app.main._list_timelapse_videos", new=AsyncMock(side_effect=OSError("ftp down"))),
+        ):
+            await _capture_timelapse_baseline_at_start(_printer(), 1, logger, archive_id=7)
+
+        assert archive.timelapse_baseline is None
+
+
+class TestManualScanUsesTheBaseline:
+    """The reporter's second symptom: pressing "Scan for Timelapse" found
+    nothing. Every strategy the endpoint had was clock-based, and their
+    printer's clock was days out, so it could not match on any of them."""
+
+    def _archive(self, baseline):
+        from datetime import datetime, timezone
+
+        a = MagicMock()
+        a.id = 64
+        a.printer_id = 1
+        a.filename = "mops.3mf"
+        a.timelapse_path = None
+        a.timelapse_baseline = baseline
+        a.started_at = datetime(2026, 7, 28, 20, 30, tzinfo=timezone.utc)
+        a.completed_at = datetime(2026, 7, 28, 21, 19, tzinfo=timezone.utc)
+        a.created_at = a.completed_at
+        return a
+
+    async def _scan(self, archive, listing, download=None, delete=None):
+        from backend.app.api.routes import archives as archives_mod
+
+        service = MagicMock()
+        service.get_archive = AsyncMock(return_value=archive)
+        service.attach_timelapse = AsyncMock(return_value=True)
+
+        session = AsyncMock()
+        session.__aenter__ = AsyncMock(return_value=session)
+        session.__aexit__ = AsyncMock()
+        session.execute = AsyncMock(
+            return_value=MagicMock(
+                scalar_one_or_none=MagicMock(return_value=_printer()),
+                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
+            )
+        )
+
+        with (
+            patch("backend.app.core.database.async_session", return_value=session),
+            patch("backend.app.api.routes.archives.ArchiveService", return_value=service),
+            patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=listing)),
+            patch(
+                "backend.app.services.bambu_ftp.get_ftp_retry_settings",
+                AsyncMock(return_value=(False, 3, 2, 30)),
+            ),
+            patch(
+                "backend.app.services.bambu_ftp.download_file_bytes_async",
+                download or AsyncMock(return_value=b"x" * 1000),
+            ),
+            patch("backend.app.services.bambu_ftp.delete_archived_timelapse", delete or AsyncMock()),
+        ):
+            return await archives_mod.scan_timelapse(archive.id, None)
+
+    @pytest.mark.asyncio
+    async def test_attaches_the_single_unclaimed_new_file(self):
+        """The printer's clock is six days out here — exactly the reporter's
+        case. Nothing in this path looks at a timestamp."""
+        archive = self._archive(["video_2026-07-21_22-49-47.avi"])
+        listing = [
+            _video("video_2026-07-21_22-49-47.avi"),
+            _video("video_2026-07-22_06-18-39.avi"),
+        ]
+
+        result = await self._scan(archive, listing)
+
+        assert result["status"] == "attached"
+        assert result["filename"] == "video_2026-07-22_06-18-39.avi"
+
+    @pytest.mark.asyncio
+    async def test_deletes_from_the_printer_after_attaching(self):
+        delete = AsyncMock()
+        archive = self._archive(["old.avi"])
+
+        await self._scan(archive, [_video("old.avi"), _video("new.avi")], delete=delete)
+
+        delete.assert_awaited_once()
+        assert delete.await_args.args[2] == "/timelapse/new.avi"
+
+    @pytest.mark.asyncio
+    async def test_baseline_showing_nothing_new_does_not_guess(self):
+        """With a baseline saying no new video exists, the clock strategies
+        must not run — otherwise a coincidental timestamp match attaches
+        someone else's video and calls it this print's."""
+        archive = self._archive(["video_2026-07-28_20-30-00.avi"])
+        # This file's embedded time is minutes from started_at, so the old
+        # timestamp strategy would have matched it confidently.
+        listing = [_video("video_2026-07-28_20-30-00.avi")]
+
+        result = await self._scan(archive, listing)
+
+        assert result["status"] == "not_found"
+
+    @pytest.mark.asyncio
+    async def test_ambiguous_baseline_offers_only_the_plausible_files(self):
+        archive = self._archive(["old.avi"])
+        listing = [_video("old.avi"), _video("candidate_a.avi"), _video("candidate_b.avi")]
+
+        result = await self._scan(archive, listing)
+
+        assert result["status"] == "not_found"
+        assert {f["name"] for f in result["available_files"]} == {"candidate_a.avi", "candidate_b.avi"}
+
+    @pytest.mark.asyncio
+    async def test_archives_without_a_baseline_keep_the_old_strategies(self):
+        """Rows predating the persisted baseline still get the best guess the
+        endpoint can make, rather than nothing at all."""
+        archive = self._archive(None)
+        listing = [_video("mops_something.avi")]  # matches by print name
+
+        result = await self._scan(archive, listing)
+
+        assert result["status"] == "attached"
+        assert result["filename"] == "mops_something.avi"
+
+
+class TestPollBounds:
+    """The poll is bounded twice on purpose."""
+
+    def test_round_cap_tracks_the_wall_clock_budget(self):
+        from backend.app.main import (
+            _TIMELAPSE_SCAN_POLL_INTERVAL_SECONDS,
+            _TIMELAPSE_SCAN_TIMEOUT_SECONDS,
+            _timelapse_scan_max_attempts,
+        )
+
+        assert (
+            _timelapse_scan_max_attempts()
+            == int(_TIMELAPSE_SCAN_TIMEOUT_SECONDS // _TIMELAPSE_SCAN_POLL_INTERVAL_SECONDS) + 1
+        )
+
+    def test_zero_interval_does_not_divide_by_zero(self, monkeypatch):
+        """The deadline alone can't bound the loop once sleeps are shortened to
+        nothing, which is exactly what a test or a future tweak would do."""
+        import backend.app.main as main_mod
+
+        monkeypatch.setattr(main_mod, "_TIMELAPSE_SCAN_POLL_INTERVAL_SECONDS", 0)
+        assert main_mod._timelapse_scan_max_attempts() > 1
+
+    def test_budget_is_much_longer_than_the_ladder_it_replaced(self):
+        """The old [5, 10, 20, 30] ladder gave up after ~65 seconds, while the
+        support bundles showed videos still arriving at the cutoff."""
+        from backend.app.main import _TIMELAPSE_SCAN_TIMEOUT_SECONDS
+
+        assert _TIMELAPSE_SCAN_TIMEOUT_SECONDS >= 300
+
+
+class TestFinishPhotoUpgrade:
+    """The print-complete notification waits ~60s for the timelapse, because
+    holding it for minutes is worse than sending a live grab. On a P1S the
+    video routinely lands later than that (p90 167s, worst observed 546s), so
+    the archive kept the live grab — taken after the end G-code dropped the
+    bed, which is the worse of the two photos. The upgrade runs afterwards."""
+
+    @pytest.mark.asyncio
+    async def test_puts_the_timelapse_frame_first_and_keeps_the_live_grab(self):
+        """First, because the gallery opens at index 0. Kept, because the
+        notification that already went out links to that exact file."""
+        from backend.app.main import _upgrade_finish_photo_from_timelapse
+
+        archive = MagicMock()
+        archive.photos = ["finish_live_grab.jpg"]
+        session = _session(archive)
+
+        with (
+            patch(
+                "backend.app.main._capture_finish_photo_from_timelapse",
+                AsyncMock(return_value=("finish_from_timelapse.jpg", False)),
+            ),
+            patch("backend.app.main.async_session", return_value=session),
+            patch("backend.app.main.ws_manager", MagicMock(send_archive_updated=AsyncMock())) as ws,
+        ):
+            await _upgrade_finish_photo_from_timelapse(7, MagicMock())
+
+        assert archive.photos == ["finish_from_timelapse.jpg", "finish_live_grab.jpg"]
+        session.commit.assert_awaited()
+        ws.send_archive_updated.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_waits_far_longer_than_the_notification_can(self):
+        from backend.app.main import (
+            _FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS,
+            _FINISH_PHOTO_UPGRADE_TIMEOUT_SECONDS,
+            _upgrade_finish_photo_from_timelapse,
+        )
+
+        capture = AsyncMock(return_value=(None, True))
+        with patch("backend.app.main._capture_finish_photo_from_timelapse", capture):
+            await _upgrade_finish_photo_from_timelapse(7, MagicMock())
+
+        assert capture.await_args.kwargs["timeout"] == _FINISH_PHOTO_UPGRADE_TIMEOUT_SECONDS
+        assert _FINISH_PHOTO_UPGRADE_TIMEOUT_SECONDS > _FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS
+        # Covers the 546s worst case seen in the support bundles.
+        assert _FINISH_PHOTO_UPGRADE_TIMEOUT_SECONDS >= 600
+
+    @pytest.mark.asyncio
+    async def test_video_never_arrives_leaves_the_archive_alone(self):
+        from backend.app.main import _upgrade_finish_photo_from_timelapse
+
+        session = _session(MagicMock())
+        with (
+            patch("backend.app.main._capture_finish_photo_from_timelapse", AsyncMock(return_value=(None, True))),
+            patch("backend.app.main.async_session", return_value=session),
+        ):
+            await _upgrade_finish_photo_from_timelapse(7, MagicMock())
+
+        session.commit.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_is_idempotent(self):
+        """A second run must not list the same photo twice."""
+        from backend.app.main import _upgrade_finish_photo_from_timelapse
+
+        archive = MagicMock()
+        archive.photos = ["finish_from_timelapse.jpg", "finish_live_grab.jpg"]
+        session = _session(archive)
+
+        with (
+            patch(
+                "backend.app.main._capture_finish_photo_from_timelapse",
+                AsyncMock(return_value=("finish_from_timelapse.jpg", False)),
+            ),
+            patch("backend.app.main.async_session", return_value=session),
+        ):
+            await _upgrade_finish_photo_from_timelapse(7, MagicMock())
+
+        assert archive.photos == ["finish_from_timelapse.jpg", "finish_live_grab.jpg"]
+        session.commit.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_missing_archive_does_not_raise(self):
+        from backend.app.main import _upgrade_finish_photo_from_timelapse
+
+        session = AsyncMock()
+        session.__aenter__ = AsyncMock(return_value=session)
+        session.__aexit__ = AsyncMock()
+        session.get = AsyncMock(return_value=None)
+
+        with (
+            patch("backend.app.main._capture_finish_photo_from_timelapse", AsyncMock(return_value=("f.jpg", False))),
+            patch("backend.app.main.async_session", return_value=session),
+        ):
+            await _upgrade_finish_photo_from_timelapse(7, MagicMock())
+
+        session.commit.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_refuses_to_delete_an_unverified_download(self):
+        """The safety rule lives with the destructive call, not at the call
+        sites — an unverified transfer may be a truncated file, and deleting
+        the source would destroy the only complete copy."""
+        from backend.app.services.bambu_ftp import delete_archived_timelapse
+
+        with patch("backend.app.services.bambu_ftp.delete_file_async", AsyncMock()) as d:
+            assert await delete_archived_timelapse("1.2.3.4", "code", "/timelapse/v.avi", verified=False) is False
+        d.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_verified_is_required_not_defaulted(self):
+        """A future call site must not be able to silently skip the check."""
+        import inspect
+
+        from backend.app.services.bambu_ftp import delete_archived_timelapse
+
+        param = inspect.signature(delete_archived_timelapse).parameters["verified"]
+        assert param.default is inspect.Parameter.empty
+        assert param.kind is inspect.Parameter.KEYWORD_ONLY
+
+
+class TestStaleBaselineCannotSurvive:
+    """A reprint reuses the archive row, so a baseline left over from the
+    previous run would have the scan diff this print against the printer's
+    state before the *last* one — and unlike NULL, a stale list reads as
+    authoritative and suppresses the fresh-snapshot fallback."""
+
+    @pytest.mark.asyncio
+    async def test_failed_capture_clears_rather_than_leaves_the_old_value(self):
+        from backend.app.main import _capture_timelapse_baseline_at_start
+
+        archive = MagicMock()
+        archive.timelapse_baseline = ["from_the_previous_run.avi"]
+        session = _session(archive)
+
+        with (
+            patch("backend.app.main.async_session", return_value=session),
+            patch("backend.app.main._list_timelapse_videos", new=AsyncMock(side_effect=OSError("ftp down"))),
+        ):
+            await _capture_timelapse_baseline_at_start(_printer(), 1, logger, archive_id=7)
+
+        assert archive.timelapse_baseline is None
+        session.commit.assert_awaited()
+
+    @pytest.mark.asyncio
+    async def test_successful_capture_overwrites_the_old_value(self):
+        from backend.app.main import _capture_timelapse_baseline_at_start
+
+        archive = MagicMock()
+        archive.timelapse_baseline = ["from_the_previous_run.avi"]
+        session = _session(archive)
+
+        with (
+            patch("backend.app.main.async_session", return_value=session),
+            patch(
+                "backend.app.main._list_timelapse_videos",
+                new=AsyncMock(return_value=([_video("now_on_the_printer.avi")], "/timelapse")),
+            ),
+        ):
+            await _capture_timelapse_baseline_at_start(_printer(), 1, logger, archive_id=7)
+
+        assert archive.timelapse_baseline == ["now_on_the_printer.avi"]
+
+
+class TestFileMustHaveStoppedGrowing:
+    """Matching the listing's size proves we received what it said, not that
+    the printer had finished writing. The scan's first look lands seconds after
+    the print ends — exactly when the video is being written — so a growing
+    file can be listed short, served short, and pass the length check. That was
+    survivable while the printer kept its copy; it isn't now that a successful
+    attach deletes it."""
+
+    @pytest.mark.asyncio
+    async def test_same_size_afterwards_is_settled(self):
+        from backend.app.services.bambu_ftp import remote_file_settled
+
+        with patch(
+            "backend.app.services.bambu_ftp.list_files_async",
+            AsyncMock(return_value=[_video("v.avi", size=4096)]),
+        ):
+            assert await remote_file_settled("1.2.3.4", "code", "/timelapse/v.avi", 4096) is True
+
+    @pytest.mark.asyncio
+    async def test_grown_since_download_is_not_settled(self):
+        """We hold a prefix of the video, not the video."""
+        from backend.app.services.bambu_ftp import remote_file_settled
+
+        with patch(
+            "backend.app.services.bambu_ftp.list_files_async",
+            AsyncMock(return_value=[_video("v.avi", size=9000)]),
+        ):
+            assert await remote_file_settled("1.2.3.4", "code", "/timelapse/v.avi", 4096) is False
+
+    @pytest.mark.asyncio
+    async def test_vanished_counts_as_settled(self):
+        """Nothing left that can grow, and nothing left to delete either."""
+        from backend.app.services.bambu_ftp import remote_file_settled
+
+        with patch(
+            "backend.app.services.bambu_ftp.list_files_async",
+            AsyncMock(return_value=[_video("something_else.avi")]),
+        ):
+            assert await remote_file_settled("1.2.3.4", "code", "/timelapse/v.avi", 4096) is True
+
+    @pytest.mark.asyncio
+    async def test_listing_failure_is_not_settled(self):
+        """ "Could not check" must not read as "safe to delete"."""
+        from backend.app.services.bambu_ftp import remote_file_settled
+
+        with patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=[])):
+            assert await remote_file_settled("1.2.3.4", "code", "/timelapse/v.avi", 4096) is False
+
+    @pytest.mark.asyncio
+    async def test_scan_discards_a_still_growing_video_without_deleting(self):
+        from backend.app.main import _attach_first_unclaimed_timelapse
+
+        delete = AsyncMock()
+        service = MagicMock()
+        service.attach_timelapse = AsyncMock(return_value=True)
+
+        with (
+            patch("backend.app.services.bambu_ftp.download_file_bytes_async", AsyncMock(return_value=b"x" * 1000)),
+            patch("backend.app.services.bambu_ftp.remote_file_settled", AsyncMock(return_value=False)),
+            patch("backend.app.services.bambu_ftp.delete_archived_timelapse", delete),
+            patch("backend.app.main.async_session", return_value=_session()),
+            patch("backend.app.main.ArchiveService", return_value=service),
+        ):
+            result = await _attach_first_unclaimed_timelapse(
+                42, _printer(), [_video("new.avi", size=1000)], set(), set(), 1, logger
+            )
+
+        assert result is False
+        service.attach_timelapse.assert_not_awaited()
+        delete.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_scan_attaches_once_the_video_has_settled(self):
+        from backend.app.main import _attach_first_unclaimed_timelapse
+
+        settled = AsyncMock(return_value=True)
+        service = MagicMock()
+        service.attach_timelapse = AsyncMock(return_value=True)
+
+        with (
+            patch("backend.app.services.bambu_ftp.download_file_bytes_async", AsyncMock(return_value=b"x" * 1000)),
+            patch("backend.app.services.bambu_ftp.remote_file_settled", settled),
+            patch("backend.app.services.bambu_ftp.delete_archived_timelapse", AsyncMock()),
+            patch("backend.app.main.async_session", return_value=_session()),
+            patch("backend.app.main.ArchiveService", return_value=service),
+            patch("backend.app.main.ws_manager", MagicMock(send_archive_updated=AsyncMock())),
+        ):
+            result = await _attach_first_unclaimed_timelapse(
+                42, _printer(), [_video("new.avi", size=1000)], set(), set(), 1, logger
+            )
+
+        assert result is True
+        # Checked against what we actually received, not against the listing.
+        assert settled.await_args.args[3] == 1000

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

@@ -783,6 +783,77 @@ class TestPushStatusCache:
 
         await bridge.stop()
 
+    @pytest.mark.asyncio
+    async def test_a2l_ams_lite_slots_survive_in_slicer_cache(self):
+        """#2697 (reported by @qoatzelcoat): every A2L slot rendered as "?" in
+        BambuStudio through the VP, while Bambuddy's own AMS card was correct.
+
+        The A2L reports its AMS Lite as physical unit id 16 but packs the
+        presence bits at base 24. Bambuddy's internal path normalises 16 -> 6
+        before the cleanup runs, so it read the right bits; the bridge parses
+        the raw printer payload itself and still held 16, so the cleanup read
+        bits 64-67 — never set — and wiped all four slots in the cache the
+        slicer reads. A slicer-side filament pick reverted on the next 1 Hz
+        push for the same reason.
+
+        The cached units must keep the physical id 16: BambuStudio addresses
+        the Lite as 16 (it sends `ams_get_rfid {ams_id: 16}` through the VP).
+        """
+        server = _make_server()
+        bridge = _make_bridge(server)
+        await bridge.start()
+
+        # Reporter's capture: tray_exist_bits 0x7000000 = bits 24/25/26 →
+        # slots 0, 1, 2 loaded, slot 3 empty.
+        bridge._on_printer_raw(
+            f"device/{H2D_SERIAL}/report",
+            json.dumps(
+                {
+                    "print": {
+                        "command": "push_status",
+                        "ams": {
+                            "ams": [
+                                {
+                                    "id": "16",
+                                    "tray": [
+                                        {
+                                            "id": "0",
+                                            "state": 3,
+                                            "tray_type": "PLA",
+                                            "tray_sub_brands": "PLA Basic",
+                                            "tray_color": "C12E1FFF",
+                                            "tray_info_idx": "GFA00",
+                                            "remain": 100,
+                                        },
+                                        {"id": "1", "state": 3, "tray_type": "PETG", "tray_info_idx": "GFG00"},
+                                        {"id": "2", "state": 3, "tray_type": "ABS", "tray_info_idx": "GFB00"},
+                                        {"id": "3", "state": 3, "tray_type": "TPU", "tray_info_idx": "GFU00"},
+                                    ],
+                                }
+                            ],
+                            "tray_exist_bits": "7000000",
+                        },
+                    }
+                }
+            ).encode(),
+        )
+        await asyncio.sleep(0.01)
+
+        cached = bridge.get_latest_print_state()
+        unit = cached["ams"]["ams"][0]
+        # The slicer-facing cache keeps the PHYSICAL id — BambuStudio speaks 16.
+        assert unit["id"] == "16"
+        trays = unit["tray"]
+        assert trays[0]["tray_type"] == "PLA", "loaded slot wrongly cleared (bit base 64 regression)"
+        assert trays[1]["tray_type"] == "PETG"
+        assert trays[2]["tray_type"] == "ABS"
+        assert trays[0]["tray_info_idx"] == "GFA00"
+        # Slot 3 is genuinely empty and still gets the normal cleanup.
+        assert trays[3]["state"] == 9
+        assert trays[3]["tray_type"] == ""
+
+        await bridge.stop()
+
     @pytest.mark.asyncio
     async def test_tray_exist_bits_shutdown_guard_preserves_cache(self):
         """#765 shutdown guard mirrored at the bridge: when the printer

+ 32 - 0
frontend/src/__tests__/components/ConnectionDiagnosticModal.test.tsx

@@ -143,6 +143,38 @@ describe('ConnectionDiagnosticModal', () => {
     spy.mockRestore();
   });
 
+  it('names a refused access code when the printer said so (#2698)', async () => {
+    const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
+      ...PROBLEM_RESULT,
+      checks: [{ id: 'mqtt_auth', status: 'fail', params: { reason: 'auth_rejected' } }],
+    });
+
+    renderModal({ printerId: 1, printerName: 'Test A1', onClose: vi.fn() });
+
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
+    // States the printer refused us, instead of the hedged "most likely wrong"
+    // text used when all we know is that there's no session.
+    expect(await screen.findByText(/refused Bambuddy's credentials/i)).toBeInTheDocument();
+    expect(screen.queryByText(/most likely wrong/i)).not.toBeInTheDocument();
+
+    spy.mockRestore();
+  });
+
+  it('hedges on the mqtt_auth failure when the printer gave no reason (#2698)', async () => {
+    const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
+      ...PROBLEM_RESULT,
+      checks: [{ id: 'mqtt_auth', status: 'fail', params: {} }],
+    });
+
+    renderModal({ printerId: 1, printerName: 'Test A1', onClose: vi.fn() });
+
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
+    expect(await screen.findByText(/most likely wrong/i)).toBeInTheDocument();
+    expect(screen.queryByText(/refused Bambuddy's credentials/i)).not.toBeInTheDocument();
+
+    spy.mockRestore();
+  });
+
   it('falls back to the generic skip text when no reason is present', async () => {
     const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
       ...PROBLEM_RESULT,

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

@@ -6353,7 +6353,8 @@ export default {
       mqtt_auth: {
         title: 'Drucker-Zugangsdaten',
         pass: 'Der Drucker hat die Verbindung akzeptiert.',
-        fail: 'Der Drucker ist erreichbar, hat die Verbindung aber abgelehnt. Der Zugangscode oder die Seriennummer ist höchstwahrscheinlich falsch. Der Zugangscode ändert sich bei jedem Umschalten des Entwicklermodus — kopieren Sie ihn erneut vom Druckerbildschirm.',
+        fail: 'Der Drucker ist erreichbar, aber Bambuddy ist nicht mit ihm verbunden. Höchstwahrscheinlich ist der Zugangscode oder die Seriennummer falsch — der Zugangscode ändert sich bei jedem Umschalten von „Nur LAN“ oder des Entwicklermodus, kopieren Sie ihn also erneut vom Druckerbildschirm. Ein Drucker, der gerade neu startet oder bereits die maximale Anzahl gleichzeitiger Verbindungen erreicht hat, sieht genauso aus.',
+        fail_auth_rejected: 'Der Drucker hat die Zugangsdaten von Bambuddy abgelehnt. Der Zugangscode oder die Seriennummer ist falsch — der Zugangscode ändert sich bei jedem Umschalten von „Nur LAN“ oder des Entwicklermodus. Kopieren Sie ihn erneut vom Druckerbildschirm und speichern Sie ihn in den Druckereinstellungen.',
         skip: 'Nicht geprüft — der Drucker konnte nicht erreicht werden.',
       },
       developer_mode: {

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

@@ -6397,7 +6397,8 @@ export default {
       mqtt_auth: {
         title: 'Printer credentials',
         pass: 'The printer accepted the connection.',
-        fail: 'The printer is reachable but rejected the connection. The access code or serial number is most likely wrong. The access code changes every time Developer Mode is toggled — re-copy it from the printer screen.',
+        fail: 'The printer is reachable but Bambuddy is not connected to it. The access code or serial number is most likely wrong — the access code changes every time LAN Only or Developer Mode is toggled, so re-copy it from the printer screen. A printer that is rebooting, or already at its limit of simultaneous connections, can look the same.',
+        fail_auth_rejected: 'The printer refused Bambuddy\'s credentials. The access code or serial number is wrong — the access code changes every time LAN Only or Developer Mode is toggled, so re-copy it from the printer screen and save it in the printer settings.',
         skip: 'Not checked — the printer could not be reached.',
       },
       developer_mode: {

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

@@ -6362,7 +6362,8 @@ export default {
       mqtt_auth: {
         title: 'Credenciales de la impresora',
         pass: 'La impresora aceptó la conexión.',
-        fail: 'La impresora es accesible pero rechazó la conexión. Lo más probable es que el código de acceso o el número de serie sean incorrectos. El código de acceso cambia cada vez que se conmuta el modo desarrollador — vuelva a copiarlo de la pantalla de la impresora.',
+        fail: 'La impresora es accesible pero Bambuddy no está conectado a ella. Lo más probable es que el código de acceso o el número de serie sean incorrectos — el código de acceso cambia cada vez que se conmuta el modo Solo LAN o el modo desarrollador, así que vuelva a copiarlo de la pantalla de la impresora. Una impresora que se está reiniciando, o que ya alcanzó su límite de conexiones simultáneas, se ve igual.',
+        fail_auth_rejected: 'La impresora rechazó las credenciales de Bambuddy. El código de acceso o el número de serie son incorrectos — el código de acceso cambia cada vez que se conmuta el modo Solo LAN o el modo desarrollador, así que vuelva a copiarlo de la pantalla de la impresora y guárdelo en la configuración de la impresora.',
         skip: 'No comprobado — no se pudo alcanzar la impresora.',
       },
       developer_mode: {

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

@@ -6343,7 +6343,8 @@ export default {
       mqtt_auth: {
         title: 'Identifiants de l\'imprimante',
         pass: 'L\'imprimante a accepté la connexion.',
-        fail: 'L\'imprimante est accessible mais a refusé la connexion. Le code d\'accès ou le numéro de série est très probablement incorrect. Le code d\'accès change chaque fois que le mode développeur est activé/désactivé — recopiez-le depuis l\'écran de l\'imprimante.',
+        fail: 'L\'imprimante est accessible mais Bambuddy n\'y est pas connecté. Le code d\'accès ou le numéro de série est très probablement incorrect — le code d\'accès change chaque fois que le mode LAN uniquement ou le mode développeur est activé/désactivé, recopiez-le donc depuis l\'écran de l\'imprimante. Une imprimante en cours de redémarrage, ou ayant déjà atteint sa limite de connexions simultanées, produit le même résultat.',
+        fail_auth_rejected: 'L\'imprimante a refusé les identifiants de Bambuddy. Le code d\'accès ou le numéro de série est incorrect — le code d\'accès change chaque fois que le mode LAN uniquement ou le mode développeur est activé/désactivé. Recopiez-le depuis l\'écran de l\'imprimante et enregistrez-le dans les paramètres de l\'imprimante.',
         skip: 'Non vérifié — l\'imprimante n\'a pas pu être jointe.',
       },
       developer_mode: {

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

@@ -6342,7 +6342,8 @@ export default {
       mqtt_auth: {
         title: 'Credenziali stampante',
         pass: 'La stampante ha accettato la connessione.',
-        fail: 'La stampante è raggiungibile ma ha rifiutato la connessione. Il codice di accesso o il numero di serie è molto probabilmente errato. Il codice di accesso cambia ogni volta che la modalità sviluppatore viene attivata/disattivata — ricopialo dallo schermo della stampante.',
+        fail: 'La stampante è raggiungibile ma Bambuddy non è connesso ad essa. Il codice di accesso o il numero di serie è molto probabilmente errato — il codice di accesso cambia ogni volta che la modalità Solo LAN o la modalità sviluppatore viene attivata/disattivata, quindi ricopialo dallo schermo della stampante. Una stampante in fase di riavvio, o che ha già raggiunto il limite di connessioni simultanee, appare allo stesso modo.',
+        fail_auth_rejected: 'La stampante ha rifiutato le credenziali di Bambuddy. Il codice di accesso o il numero di serie è errato — il codice di accesso cambia ogni volta che la modalità Solo LAN o la modalità sviluppatore viene attivata/disattivata. Ricopialo dallo schermo della stampante e salvalo nelle impostazioni della stampante.',
         skip: 'Non verificato — impossibile raggiungere la stampante.',
       },
       developer_mode: {

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

@@ -6354,7 +6354,8 @@ export default {
       mqtt_auth: {
         title: 'プリンター認証情報',
         pass: 'プリンターが接続を受け入れました。',
-        fail: 'プリンターには到達できますが、接続を拒否されました。アクセスコードまたはシリアル番号が間違っている可能性が高いです。アクセスコードは開発者モードを切り替えるたびに変わります — プリンター画面から再度コピーしてください。',
+        fail: 'プリンターには到達できますが、Bambuddy は接続されていません。アクセスコードまたはシリアル番号が間違っている可能性が高いです — アクセスコードは LAN のみモードや開発者モードを切り替えるたびに変わるため、プリンター画面から再度コピーしてください。再起動中のプリンターや、同時接続数の上限に達しているプリンターでも同じ表示になります。',
+        fail_auth_rejected: 'プリンターが Bambuddy の認証情報を拒否しました。アクセスコードまたはシリアル番号が間違っています — アクセスコードは LAN のみモードや開発者モードを切り替えるたびに変わります。プリンター画面から再度コピーし、プリンター設定に保存してください。',
         skip: '未確認 — プリンターに到達できませんでした。',
       },
       developer_mode: {

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

@@ -6423,7 +6423,8 @@ export default {
       mqtt_auth: {
         title: '프린터 자격증명',
         pass: '프린터가 연결을 수락했습니다.',
-        fail: '프린터에 연결됐지만 연결을 거부했습니다. 액세스 코드 또는 시리얼 번호가 잘못됐을 가능성이 높습니다. 개발자 모드를 토글할 때마다 액세스 코드가 변경됩니다 — 프린터 화면에서 다시 복사하세요.',
+        fail: '프린터에 도달할 수 있지만 Bambuddy가 연결되지 않았습니다. 액세스 코드 또는 시리얼 번호가 잘못됐을 가능성이 높습니다 — LAN 전용 모드나 개발자 모드를 토글할 때마다 액세스 코드가 변경되므로 프린터 화면에서 다시 복사하세요. 재부팅 중이거나 이미 동시 연결 한도에 도달한 프린터도 똑같이 보입니다.',
+        fail_auth_rejected: '프린터가 Bambuddy의 자격증명을 거부했습니다. 액세스 코드 또는 시리얼 번호가 잘못됐습니다 — LAN 전용 모드나 개발자 모드를 토글할 때마다 액세스 코드가 변경됩니다. 프린터 화면에서 다시 복사한 뒤 프린터 설정에 저장하세요.',
         skip: '확인하지 않음 — 프린터에 연결할 수 없었습니다.'
       },
       developer_mode: {

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

@@ -6342,7 +6342,8 @@ export default {
       mqtt_auth: {
         title: 'Credenciais da impressora',
         pass: 'A impressora aceitou a conexão.',
-        fail: 'A impressora está acessível mas recusou a conexão. O código de acesso ou o número de série provavelmente está incorreto. O código de acesso muda toda vez que o Modo Desenvolvedor é alternado — copie-o novamente da tela da impressora.',
+        fail: 'A impressora está acessível mas o Bambuddy não está conectado a ela. O código de acesso ou o número de série provavelmente está incorreto — o código de acesso muda toda vez que o modo Somente LAN ou o Modo Desenvolvedor é alternado, então copie-o novamente da tela da impressora. Uma impressora reiniciando, ou que já atingiu seu limite de conexões simultâneas, aparece do mesmo jeito.',
+        fail_auth_rejected: 'A impressora recusou as credenciais do Bambuddy. O código de acesso ou o número de série está incorreto — o código de acesso muda toda vez que o modo Somente LAN ou o Modo Desenvolvedor é alternado. Copie-o novamente da tela da impressora e salve-o nas configurações da impressora.',
         skip: 'Não verificado — não foi possível alcançar a impressora.',
       },
       developer_mode: {

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

@@ -5984,7 +5984,8 @@ export default {
       mqtt_auth: {
         title: "Учётные данные принтера",
         pass: "Принтер принял подключение.",
-        fail: "Принтер доступен, но отклонил подключение. Скорее всего, неверны код доступа или серийный номер. Код доступа изменяется при каждом переключении режима разработчика — снова скопируйте его с экрана принтера.",
+        fail: "Принтер доступен, но Bambuddy к нему не подключён. Скорее всего, неверны код доступа или серийный номер — код доступа изменяется при каждом переключении режима «Только LAN» или режима разработчика, поэтому снова скопируйте его с экрана принтера. Так же выглядит принтер, который перезагружается или уже исчерпал лимит одновременных подключений.",
+        fail_auth_rejected: "Принтер отклонил учётные данные Bambuddy. Код доступа или серийный номер неверны — код доступа изменяется при каждом переключении режима «Только LAN» или режима разработчика. Снова скопируйте его с экрана принтера и сохраните в настройках принтера.",
         skip: "Не проверено — принтер недоступен.",
       },
       developer_mode: {

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

@@ -6293,7 +6293,8 @@ export default {
       mqtt_auth: {
         title: 'Yazıcı kimlik bilgileri',
         pass: 'Yazıcı bağlantıyı kabul etti.',
-        fail: 'Yazıcı erişilebilir ancak bağlantıyı reddetti. Büyük olasılıkla erişim kodu veya seri numarası yanlış. Erişim kodu, Geliştirici Modu her açılıp kapatıldığında değişir — yazıcı ekranından yeniden kopyalayın.',
+        fail: 'Yazıcıya erişilebiliyor ancak Bambuddy ona bağlı değil. Büyük olasılıkla erişim kodu veya seri numarası yanlış — erişim kodu, Yalnızca LAN veya Geliştirici Modu her açılıp kapatıldığında değişir, bu yüzden yazıcı ekranından yeniden kopyalayın. Yeniden başlamakta olan veya eşzamanlı bağlantı sınırına ulaşmış bir yazıcı da aynı görünür.',
+        fail_auth_rejected: 'Yazıcı, Bambuddy\'nin kimlik bilgilerini reddetti. Erişim kodu veya seri numarası yanlış — erişim kodu, Yalnızca LAN veya Geliştirici Modu her açılıp kapatıldığında değişir. Yazıcı ekranından yeniden kopyalayın ve yazıcı ayarlarına kaydedin.',
         skip: 'Kontrol edilmedi — yazıcıya erişilemedi.',
       },
       developer_mode: {

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

@@ -6397,7 +6397,8 @@ export default {
       mqtt_auth: {
         title: "Облікові дані принтера",
         pass: "Принтер прийняв підключення.",
-        fail: "Принтер доступний, але відхилив з’єднання. Найімовірніше, указано неправильний код доступу або серійний номер. Код доступу змінюється після кожного перемикання режиму розробника — знову скопіюйте його з екрана принтера.",
+        fail: "Принтер доступний, але Bambuddy до нього не під’єднано. Найімовірніше, указано неправильний код доступу або серійний номер — код доступу змінюється після кожного перемикання режиму «Лише LAN» або режиму розробника, тож знову скопіюйте його з екрана принтера. Так само виглядає принтер, який перезавантажується або вже вичерпав ліміт одночасних з’єднань.",
+        fail_auth_rejected: "Принтер відхилив облікові дані Bambuddy. Код доступу або серійний номер неправильний — код доступу змінюється після кожного перемикання режиму «Лише LAN» або режиму розробника. Знову скопіюйте його з екрана принтера та збережіть у налаштуваннях принтера.",
         skip: "Не позначено — принтер недоступний.",
       },
       developer_mode: {

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

@@ -6341,7 +6341,8 @@ export default {
       mqtt_auth: {
         title: '打印机凭据',
         pass: '打印机已接受连接。',
-        fail: '打印机可达,但拒绝了连接。访问代码或序列号很可能有误。每次切换开发者模式时访问代码都会更改 — 请从打印机屏幕重新复制。',
+        fail: '打印机可达,但 Bambuddy 未与其建立连接。访问代码或序列号很可能有误 — 每次切换仅局域网模式或开发者模式时访问代码都会更改,请从打印机屏幕重新复制。正在重启或已达到同时连接数上限的打印机看起来也是这样。',
+        fail_auth_rejected: '打印机拒绝了 Bambuddy 的凭据。访问代码或序列号有误 — 每次切换仅局域网模式或开发者模式时访问代码都会更改。请从打印机屏幕重新复制,并保存到打印机设置中。',
         skip: '未检查 — 无法连接到打印机。',
       },
       developer_mode: {

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

@@ -6341,7 +6341,8 @@ export default {
       mqtt_auth: {
         title: '印表機認證資訊',
         pass: '印表機已接受連線。',
-        fail: '印表機可達,但拒絕了連線。存取碼或序號很可能有誤。每次切換開發者模式時存取碼都會變更 — 請從印表機螢幕重新複製。',
+        fail: '印表機可達,但 Bambuddy 未與其建立連線。存取碼或序號很可能有誤 — 每次切換僅區域網路模式或開發者模式時存取碼都會變更,請從印表機螢幕重新複製。正在重新啟動或已達到同時連線數上限的印表機看起來也是這樣。',
+        fail_auth_rejected: '印表機拒絕了 Bambuddy 的認證資訊。存取碼或序號有誤 — 每次切換僅區域網路模式或開發者模式時存取碼都會變更。請從印表機螢幕重新複製,並儲存到印表機設定中。',
         skip: '未檢查 — 無法連線到印表機。',
       },
       developer_mode: {

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-xPJs-OAQ.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-B4oB3n1m.js"></script>
+    <script type="module" crossorigin src="/assets/index-xPJs-OAQ.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-D4bpNaiw.css">
   </head>
   <body>

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