Sfoglia il codice sorgente

Merge branch 'dev' into feature/billing

MartinNYHC 4 settimane fa
parent
commit
406cf71149
49 ha cambiato i file con 2285 aggiunte e 116 eliminazioni
  1. 1 1
      CHANGELOG.md
  2. 15 1
      backend/app/api/routes/archives.py
  3. 2 0
      backend/app/api/routes/cloud.py
  4. 11 0
      backend/app/api/routes/printers.py
  5. 16 3
      backend/app/main.py
  6. 6 0
      backend/app/schemas/cloud.py
  7. 168 0
      backend/app/services/bambu_cloud.py
  8. 79 2
      backend/app/services/bambu_ftp.py
  9. 11 0
      backend/app/services/log_health.py
  10. 18 12
      backend/app/services/makerworld.py
  11. 221 14
      backend/app/services/print_scheduler.py
  12. 63 3
      backend/app/services/printer_diagnostic.py
  13. 32 0
      backend/tests/integration/test_printers_api.py
  14. 46 0
      backend/tests/integration/test_timelapse_scan_session.py
  15. 8 1
      backend/tests/unit/services/conftest.py
  16. 112 0
      backend/tests/unit/services/test_bambu_ftp.py
  17. 97 4
      backend/tests/unit/services/test_printer_diagnostic.py
  18. 242 0
      backend/tests/unit/test_cloud_captcha_2790.py
  19. 424 0
      backend/tests/unit/test_scheduler_class_target_smart_plug_2786.py
  20. 47 0
      frontend/src/__tests__/components/FilamentHoverCard.test.tsx
  21. 78 0
      frontend/src/__tests__/pages/CloudLoginCaptcha.test.tsx
  22. 120 0
      frontend/src/__tests__/pages/GCodeViewerPage.test.tsx
  23. 6 0
      frontend/src/api/client.ts
  24. 1 1
      frontend/src/components/Card.tsx
  25. 1 1
      frontend/src/components/ContextMenu.tsx
  26. 22 17
      frontend/src/components/FilamentHoverCard.tsx
  27. 16 2
      frontend/src/i18n/locales/de.ts
  28. 16 2
      frontend/src/i18n/locales/en.ts
  29. 16 2
      frontend/src/i18n/locales/es.ts
  30. 16 2
      frontend/src/i18n/locales/fr.ts
  31. 16 2
      frontend/src/i18n/locales/it.ts
  32. 16 2
      frontend/src/i18n/locales/ja.ts
  33. 17 3
      frontend/src/i18n/locales/ko.ts
  34. 16 2
      frontend/src/i18n/locales/pt-BR.ts
  35. 16 2
      frontend/src/i18n/locales/ru.ts
  36. 16 2
      frontend/src/i18n/locales/tr.ts
  37. 16 2
      frontend/src/i18n/locales/uk.ts
  38. 16 2
      frontend/src/i18n/locales/zh-CN.ts
  39. 16 2
      frontend/src/i18n/locales/zh-TW.ts
  40. 22 0
      frontend/src/index.css
  41. 4 2
      frontend/src/pages/ArchivesPage.tsx
  42. 92 19
      frontend/src/pages/GCodeViewerPage.tsx
  43. 42 4
      frontend/src/pages/ProfilesPage.tsx
  44. 1 1
      frontend/src/pages/QueuePage.tsx
  45. 65 0
      frontend/src/utils/framing.ts
  46. 0 0
      static/assets/index-CDkM7wuh.js
  47. 0 1
      static/assets/index-DJ8Q_OV9.css
  48. 1 0
      static/assets/index-ud1tvgv1.css
  49. 2 2
      static/index.html

File diff suppressed because it is too large
+ 1 - 1
CHANGELOG.md


+ 15 - 1
backend/app/api/routes/archives.py

@@ -2295,6 +2295,7 @@ async def scan_timelapse(
     from backend.app.services.bambu_ftp import (
         delete_archived_timelapse,
         download_file_bytes_async,
+        ftps_handshake_blocked,
         get_ftp_retry_settings,
         list_files_async,
         remote_file_settled,
@@ -2330,6 +2331,8 @@ async def scan_timelapse(
     # Different printer models use different paths
     files = []
     for timelapse_path in ["/timelapse", "/timelapse/video", "/record", "/recording"]:
+        if ftps_handshake_blocked(printer.ip_address):
+            break
         try:
             files = await list_files_async(
                 printer.ip_address, printer.access_code, timelapse_path, printer_model=printer.model
@@ -2339,7 +2342,18 @@ async def scan_timelapse(
         except Exception:
             continue
     if not files:
-        raise HTTPException(500, "Failed to connect to printer or no timelapse directory found")
+        # "Couldn't reach the printer" and "the printer has no timelapse
+        # directory" are different problems with different fixes, and both used
+        # to come back as one 500 (#2780). A printer whose file service stopped
+        # answering over TLS needs a restart, and nothing here will work until
+        # it gets one.
+        if ftps_handshake_blocked(printer.ip_address):
+            raise HTTPException(
+                503,
+                f"Printer {printer.ip_address} is not answering its file service over TLS. "
+                "Restart the printer and try again.",
+            )
+        raise HTTPException(404, "No timelapse directory found on the printer")
 
     # Look for matching timelapse
     matching_file = None

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

@@ -528,6 +528,7 @@ async def login(
             message=result.get("message", "Unknown error"),
             verification_type=result.get("verification_type"),
             tfa_key=result.get("tfa_key"),
+            reason=result.get("reason"),
         )
     except BambuCloudAuthError as e:
         raise HTTPException(status_code=401, detail=str(e))
@@ -573,6 +574,7 @@ async def verify_code(
             success=result.get("success", False),
             needs_verification=False,
             message=result.get("message", "Unknown error"),
+            reason=result.get("reason"),
         )
     except BambuCloudAuthError as e:
         raise HTTPException(status_code=401, detail=str(e))

+ 11 - 0
backend/app/api/routes/printers.py

@@ -46,6 +46,7 @@ from backend.app.services.bambu_ftp import (
     delete_file_async,
     download_file_bytes_async,
     download_file_try_paths_async,
+    ftps_handshake_blocked,
     get_cached_3mf,
     get_storage_info_async,
     list_files_async,
@@ -1225,6 +1226,16 @@ async def _produce_cover_image(
         last_error = None
 
         for attempt in range(max_retries + 1):
+            if ftps_handshake_blocked(printer.ip_address):
+                # Nothing to retry: the printer is not completing a TLS
+                # handshake on port 990, so no path and no attempt reaches it
+                # (#2780). Report the real cause instead of the 404 below,
+                # which would read as "this print has no cover".
+                raise HTTPException(
+                    503,
+                    f"Printer {printer.ip_address} is not answering its file service over TLS. "
+                    "Restart the printer and try again.",
+                )
             try:
                 downloaded = await download_file_try_paths_async(
                     printer.ip_address,

+ 16 - 3
backend/app/main.py

@@ -92,6 +92,7 @@ from backend.app.services.bambu_ftp import (
     cache_3mf_download,
     clear_3mf_cache,
     download_file_async,
+    ftps_handshake_blocked,
     get_cached_3mf,
     get_ftp_retry_settings,
     with_ftp_retry,
@@ -3384,6 +3385,16 @@ async def on_print_start(printer_id: int, data: dict):
             temp_path.parent.mkdir(parents=True, exist_ok=True)
 
             for remote_path in remote_paths:
+                if ftps_handshake_blocked(printer.ip_address):
+                    # The printer's FTPS service is not completing a TLS
+                    # handshake, so it has no path we could reach — walking the
+                    # remaining candidates only re-runs the same failure
+                    # (#2780). Fall through to the no-3MF archive now.
+                    logger.warning(
+                        "Giving up on the 3MF for printer %s: its file service is not answering over TLS",
+                        printer_id,
+                    )
+                    break
                 logger.debug("Trying FTP download: %s", remote_path)
                 try:
                     if ftp_retry_enabled:
@@ -3425,12 +3436,14 @@ async def on_print_start(printer_id: int, data: dict):
                 except Exception as e:
                     logger.debug("FTP download failed for %s: %s", remote_path, e)
 
-            if downloaded_filename:
+            if downloaded_filename or ftps_handshake_blocked(printer.ip_address):
                 break
 
         # If still not found, try listing directories to find matching file
-        # Different printer models use different directory structures
-        if not downloaded_filename and (filename or subtask_name):
+        # Different printer models use different directory structures. Skipped
+        # when the printer's FTPS handshake is failing — the directory walk is
+        # five more connections that cannot get further than the download did.
+        if not downloaded_filename and (filename or subtask_name) and not ftps_handshake_blocked(printer.ip_address):
             search_term = (subtask_name or filename).lower().replace(".gcode", "").replace(".3mf", "")
             logger.info("Direct FTP download failed, searching directories for '%s'", search_term)
             search_dirs = ["/cache", "/model", "/data", "/data/Metadata", "/"]

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

@@ -30,6 +30,12 @@ class CloudLoginResponse(BaseModel):
     message: str
     verification_type: str | None = None  # "email" or "totp"
     tfa_key: str | None = None  # Key needed for TOTP verification
+    # Machine-readable cause of a failure, when we know it. Currently only
+    # "captcha" — Bambu's anti-abuse layer is challenging this network and no
+    # credential will be accepted until it clears (#2790). The UI needs this to
+    # explain the situation in place, rather than flashing ``message`` as a
+    # toast that vanishes and leaves the user retrying a password that is fine.
+    reason: str | None = None
 
 
 class CloudAuthStatus(BaseModel):

+ 168 - 0
backend/app/services/bambu_cloud.py

@@ -124,6 +124,110 @@ def _detect_cloudflare_challenge(response) -> str | None:
     return None
 
 
+# Bambu's own anti-abuse layer — distinct from the Cloudflare edge above —
+# answers a request it has flagged with HTTP 418 and a challenge body:
+#
+#     {"captchaId": "...", "error": "We need you to confirm you are not a robot"}
+#
+# The flag is keyed to the source IP and covers api.bambulab.com as a whole:
+# the same 418 turns up on the login endpoint and on the design-service
+# endpoints MakerWorld imports use. It clears on its own after a few hours of
+# quiet traffic, and there is no server-side solve — a CAPTCHA is designed to be
+# unanswerable without a real browser, and the challenge id is of no use to us
+# because we have nowhere to render the widget.
+#
+# It reaches ``login_request`` as a perfectly well-formed JSON body, so
+# ``_detect_cloudflare_challenge`` above never fires on it. Before #2790 the
+# generic error path then lifted Bambu's sentence out of ``error`` and showed it
+# as a bare toast: the reporter saw "We need you to confirm you are not a robot"
+# with no challenge, no explanation and nothing to click, and filed it as a
+# Bambuddy bug.
+_CAPTCHA_HTTP_STATUS = 418
+
+# Markers that identify a 418 as the CAPTCHA challenge rather than some other
+# refusal. ``captchaId`` is the reliable one; the wording is matched too because
+# Bambu has shipped the challenge under more than one phrasing.
+_CAPTCHA_BODY_MARKERS = ("captchaid", "captcha", "robot")
+
+CAPTCHA_USER_MESSAGE = (
+    "Bambu Cloud is challenging this network with a CAPTCHA before it will accept a sign-in, "
+    "and there is no way to answer it from Bambuddy. Your email and password are not the "
+    "problem. The block is tied to your public IP address and normally clears by itself within "
+    "a few hours — retrying repeatedly extends it. To sign in now, use 'Use access token "
+    "instead' and paste a token taken from a browser session."
+)
+
+# How long to stop sending sign-in requests to a Bambu region after it answered
+# with a CAPTCHA challenge. The reporter's log shows four attempts in eighteen
+# seconds, which is exactly the traffic pattern that deepens the block: every
+# extra request is more evidence for the thing that flagged us. Five minutes is
+# short against the hours the block itself lasts — the point is not to wait it
+# out here, only to stop Bambuddy from making it worse while the user reads the
+# explanation.
+_CAPTCHA_COOLOFF_SECONDS = 300.0
+
+# API base URL -> monotonic time its cool-off expires. Keyed by base URL because
+# the block lives at the edge in front of one region: being challenged on
+# api.bambulab.com says nothing about api.bambulab.cn.
+_captcha_blocked_until: dict[str, float] = {}
+
+
+def is_captcha_challenge(response) -> bool:
+    """Whether Bambu answered with an anti-abuse CAPTCHA challenge.
+
+    Requires the 418 status *and* a challenge marker in the body, so an
+    unrelated 418 is not reported to the user as "solve a CAPTCHA" — that would
+    send them looking for a widget that was never there, which is the exact
+    confusion #2790 is about. Callers that want to say something about a bare
+    418 must handle it themselves.
+
+    Shared by the Bambu Cloud and MakerWorld services: same edge, same body.
+    """
+    try:
+        status = int(getattr(response, "status_code", 0) or 0)
+    except (TypeError, ValueError):
+        return False
+    if status != _CAPTCHA_HTTP_STATUS:
+        return False
+    try:
+        data = response.json()
+    except Exception:
+        data = None
+    if isinstance(data, dict):
+        # Field *names* count as well as their text: the challenge is
+        # identified by carrying a ``captchaId`` at all, whatever it says.
+        parts = [str(key) for key in data]
+        parts += [str(data[key]) for key in ("captchaId", "error", "message", "detail") if data.get(key)]
+        haystack = " ".join(parts).lower()
+    else:
+        # Not JSON (or not an object) — fall back to the raw body so a
+        # challenge served as HTML is still recognised rather than reported as
+        # an unexplained failure.
+        try:
+            haystack = (response.text or "").lower()
+        except Exception:
+            return False
+    return any(marker in haystack for marker in _CAPTCHA_BODY_MARKERS)
+
+
+def captcha_cooloff_active(base_url: str) -> bool:
+    """Whether sign-in requests to ``base_url`` are still held back after a
+    CAPTCHA challenge. Expired entries are dropped on the way past, so the dict
+    cannot grow past one entry per region."""
+    deadline = _captcha_blocked_until.get(base_url)
+    if deadline is None:
+        return False
+    if time.monotonic() >= deadline:
+        del _captcha_blocked_until[base_url]
+        return False
+    return True
+
+
+def note_captcha_challenge(base_url: str) -> None:
+    """Start the cool-off for ``base_url`` after a challenge was seen."""
+    _captcha_blocked_until[base_url] = time.monotonic() + _CAPTCHA_COOLOFF_SECONDS
+
+
 # The `/v1/iot-service/api/slicer/setting` endpoint subtree — the plural GET
 # for the list, the singular GET/DELETE for a specific preset by setting_id, and
 # the POST for create — requires a `version` query parameter in the XX.YY.ZZ.WW
@@ -317,12 +421,62 @@ class BambuCloudService:
             headers["Authorization"] = f"Bearer {self.access_token}"
         return headers
 
+    def _captcha_refusal(self) -> dict:
+        """The result every sign-in call returns while Bambu is challenging us.
+
+        ``reason`` is what lets the UI tell this apart from a wrong password and
+        render the explanation next to the access-token route, instead of
+        flashing Bambu's own one-liner as a toast that then disappears (#2790).
+        """
+        return {
+            "success": False,
+            "needs_verification": False,
+            "reason": "captcha",
+            "message": CAPTCHA_USER_MESSAGE,
+        }
+
+    def _captcha_cooloff_holds(self, origin: str | None = None) -> bool:
+        """Whether to refuse a sign-in locally because Bambu just challenged us.
+
+        Keyed by the origin the call actually goes to. The TOTP step talks to
+        ``bambulab.com`` while everything else talks to ``api.bambulab.com``, and
+        a challenge seen on one must not strand a user halfway through a
+        two-factor sign-in on the other.
+        """
+        origin = origin or self.base_url
+        if not captcha_cooloff_active(origin):
+            return False
+        logger.warning(
+            "Bambu Cloud is challenging this network with a CAPTCHA — not sending the sign-in to %s. "
+            "The challenge cannot be answered from Bambuddy and normally clears within a few hours.",
+            origin,
+        )
+        return True
+
+    def _note_captcha(self, response, origin: str | None = None) -> bool:
+        """Record and log a CAPTCHA challenge. Returns whether it was one."""
+        if not is_captcha_challenge(response):
+            return False
+        origin = origin or self.base_url
+        logger.warning(
+            "Bambu Cloud is challenging this network with a CAPTCHA (HTTP %s from %s). Sign-in cannot "
+            "complete until the challenge clears; pausing sign-in requests for %.0fs so retries do not "
+            "extend the block.",
+            response.status_code,
+            origin,
+            _CAPTCHA_COOLOFF_SECONDS,
+        )
+        note_captcha_challenge(origin)
+        return True
+
     async def login_request(self, email: str, password: str) -> dict:
         """
         Initiate login - this will trigger either email verification or TOTP prompt.
 
         Returns dict with login status, verification type, and tfaKey if needed.
         """
+        if self._captcha_cooloff_holds():
+            return self._captcha_refusal()
         try:
             response = await self._client.post(
                 f"{self.base_url}/v1/user-service/user/login",
@@ -333,6 +487,9 @@ class BambuCloudService:
                 },
             )
 
+            if self._note_captcha(response):
+                return self._captcha_refusal()
+
             try:
                 data = response.json()
             except Exception as json_err:
@@ -388,6 +545,8 @@ class BambuCloudService:
         """
         Complete login with email verification code.
         """
+        if self._captcha_cooloff_holds():
+            return self._captcha_refusal()
         try:
             response = await self._client.post(
                 f"{self.base_url}/v1/user-service/user/login",
@@ -398,6 +557,9 @@ class BambuCloudService:
                 },
             )
 
+            if self._note_captcha(response):
+                return self._captcha_refusal()
+
             try:
                 data = response.json()
             except Exception as json_err:
@@ -472,6 +634,9 @@ class BambuCloudService:
             web_origin = "https://bambulab.cn" if "bambulab.cn" in self.base_url else "https://bambulab.com"
             tfa_url = f"{web_origin}/api/sign-in/tfa"
 
+            if self._captcha_cooloff_holds(web_origin):
+                return self._captcha_refusal()
+
             # #2696: the web origin is CSRF-protected (double submit). Without
             # both halves the endpoint 403s before it ever evaluates the code,
             # which surfaced to users as a permanent, misleading "Invalid code".
@@ -509,6 +674,9 @@ class BambuCloudService:
                 f"TOTP verify response: status={response.status_code}, body={response.text[:200] if response.text else '(empty)'}"
             )
 
+            if self._note_captcha(response, web_origin):
+                return self._captcha_refusal()
+
             # Handle empty response
             if not response.text or not response.text.strip():
                 logger.warning("TOTP verification returned empty response (status %s)", response.status_code)

+ 79 - 2
backend/app/services/bambu_ftp.py

@@ -88,6 +88,28 @@ class DeleteResult(Enum):
     FAILED = "failed"
 
 
+# How long to stop opening FTPS connections to a printer after its TLS
+# handshake failed (#2780).
+#
+# ``WRONG_VERSION_NUMBER`` on port 990 means the printer answered with
+# something that is not a TLS record at all — its file service is wedged, and
+# no path, retry or SSL option can talk to it until the printer is restarted.
+# Two support bundles show that state lasting for days: one X2D served clean
+# FTPS for five days, flipped on 2026-07-19, and then failed every single
+# handshake for the next eight (zero successes, 3511 failures).
+#
+# Without a gate every candidate path re-runs the same doomed handshake: the
+# 3MF lookup alone walks 6 filename variants x 5 directories x 4 retries, and
+# the cover and timelapse scans run their own sweeps on top. That is where
+# those thousands of failures come from — one wedged printer, hammered.
+#
+# Five minutes is short enough that a power-cycled printer is picked up on the
+# next print (and any successful connect clears the gate immediately), long
+# enough that a wedged one is contacted twice an hour instead of hundreds of
+# times a minute.
+_HANDSHAKE_COOLOFF_SECONDS = 300.0
+
+
 class FileNotOnPrinterError(Exception):
     """Raised when a remote FTP path returns 550 (file not found).
 
@@ -190,6 +212,10 @@ class BambuFTPClient:
     # Maps IP -> "prot_p" or "prot_c"
     _mode_cache: dict[str, str] = {}
 
+    # Printers whose FTPS handshake just failed, mapped to the monotonic time
+    # their cool-off expires. See ``_HANDSHAKE_COOLOFF_SECONDS``.
+    _handshake_blocked_until: dict[str, float] = {}
+
     def __init__(
         self,
         ip_address: str,
@@ -233,8 +259,36 @@ class BambuFTPClient:
         # Default: try prot_p first (will fall back if needed)
         return False
 
+    @classmethod
+    def handshake_blocked(cls, ip_address: str) -> bool:
+        """True while *ip_address* is inside its post-handshake-failure cool-off.
+
+        Public so a caller sweeping many candidate paths can stop after the
+        first one rather than walking the rest against a printer that cannot
+        complete a TLS handshake (#2780).
+        """
+        deadline = cls._handshake_blocked_until.get(ip_address)
+        if deadline is None:
+            return False
+        if time.monotonic() >= deadline:
+            # Drop it on the way past rather than leaving an entry per printer
+            # this process has ever failed against.
+            del cls._handshake_blocked_until[ip_address]
+            return False
+        return True
+
     def connect(self) -> bool:
-        """Connect to the printer FTP server (implicit FTPS on port 990)."""
+        """Connect to the printer FTP server (implicit FTPS on port 990).
+
+        Returns False without touching the network while the printer is inside
+        the cool-off a previous TLS handshake failure opened (#2780).
+        """
+        if self.handshake_blocked(self.ip_address):
+            logger.debug(
+                "FTP connect to %s skipped: FTPS handshake failed recently, cooling off",
+                self.ip_address,
+            )
+            return False
         try:
             use_prot_c = self._should_use_prot_c()
             from backend.app.services.ftp_profiles import get_ftp_profile
@@ -277,7 +331,20 @@ class BambuFTPClient:
             self._ftp = None
             return False
         except ssl.SSLError as e:
-            logger.warning("FTP SSL error connecting to %s: %s", self.ip_address, e)
+            # Not a transient failure and not something another path or another
+            # retry can route around: the printer's file service answered port
+            # 990 with something that isn't TLS. Say so once, in words the
+            # reporter can act on, and stop knocking for a while (#2780).
+            logger.warning(
+                "FTP SSL error connecting to %s: %s — the printer's file service is not answering "
+                "with TLS on port %s. Print files, covers and timelapses cannot be fetched from it "
+                "until the printer is restarted. Pausing FTP to this printer for %.0fs.",
+                self.ip_address,
+                e,
+                self.FTP_PORT,
+                _HANDSHAKE_COOLOFF_SECONDS,
+            )
+            self._handshake_blocked_until[self.ip_address] = time.monotonic() + _HANDSHAKE_COOLOFF_SECONDS
             self._ftp = None
             return False
         except (OSError, ftplib.Error) as e:
@@ -816,6 +883,16 @@ class BambuFTPClient:
         return result if result else None
 
 
+def ftps_handshake_blocked(ip_address: str) -> bool:
+    """True while this printer's FTPS handshake cool-off is still running.
+
+    Callers that walk a list of candidate paths use this to give up on the
+    remaining candidates: the failure is at the transport, below any path, so
+    every one of them would fail identically (#2780).
+    """
+    return BambuFTPClient.handshake_blocked(ip_address)
+
+
 # Shared 3MF download cache (#972).
 #
 # Both the cover thumbnail endpoint (api/routes/printers.py) and the archive

+ 11 - 0
backend/app/services/log_health.py

@@ -123,6 +123,17 @@ SIGNATURES: tuple[LogSignature, ...] = (
         logger_prefix="backend.app.services.camera",
         min_count=3,
     ),
+    LogSignature(
+        # Bambu's anti-abuse layer is challenging this network with a CAPTCHA,
+        # so no Bambu Cloud sign-in can complete. Nothing in the install is
+        # broken and no credential will help — see bambu_cloud.is_captcha_challenge.
+        id="bambu-cloud-captcha",
+        patterns=_compile(r"challenging this network with a CAPTCHA"),
+        severity="warning",
+        category="environment",
+        wiki_anchor="bambu-cloud-captcha",
+        logger_prefix="backend.app.services.bambu_cloud",
+    ),
     LogSignature(
         # SQLite write contention. Surfaces inside exception tracebacks; folded
         # continuation lines are part of the entry message, so this still

+ 18 - 12
backend/app/services/makerworld.py

@@ -28,7 +28,7 @@ from urllib.parse import urlparse
 import certifi
 import httpx
 
-from backend.app.services.bambu_cloud import is_expiry_401
+from backend.app.services.bambu_cloud import is_captcha_challenge, is_expiry_401
 
 logger = logging.getLogger(__name__)
 
@@ -331,18 +331,24 @@ class MakerWorldService:
         if response.status_code == 404:
             raise MakerWorldNotFoundError(f"MakerWorld resource not found: {path}")
         if response.status_code == 418:
-            # MakerWorld's anti-abuse layer challenges the source IP with a
-            # CAPTCHA (``{"captchaId":"...","error":"We need to confirm..."}``).
-            # This is application-level, not Cloudflare-edge, and clears
-            # on its own within 1–4 hours of quiet traffic. There's no
-            # server-side solve — CAPTCHAs are intentionally unsolvable
-            # without a real browser. Surface the upstream message so the
-            # user can recognise it and reach for the "Open on MakerWorld"
-            # fallback instead of thinking the feature is broken.
-            upstream = _extract_upstream_error(response)
-            if upstream and "robot" in upstream.lower():
+            # Bambu's anti-abuse layer challenges the source IP with a CAPTCHA
+            # (``{"captchaId":"...","error":"We need to confirm..."}``). This is
+            # application-level, not Cloudflare-edge, and clears on its own
+            # within 1–4 hours of quiet traffic. There's no server-side solve —
+            # CAPTCHAs are intentionally unsolvable without a real browser.
+            # Surface the upstream message so the user can recognise it and
+            # reach for the "Open on MakerWorld" fallback instead of thinking
+            # the feature is broken.
+            #
+            # The same challenge also lands on the Bambu Cloud sign-in endpoint,
+            # so the shape test lives in ``bambu_cloud`` and is shared (#2790).
+            # It used to be a bare "robot" substring check on the error text,
+            # which missed a challenge worded any other way.
+            if is_captcha_challenge(response):
+                upstream = _extract_upstream_error(response)
+                detail = f" ({upstream})" if upstream else ""
                 raise MakerWorldUnavailableError(
-                    f"MakerWorld is challenging this IP with a CAPTCHA ({upstream}). "
+                    f"MakerWorld is challenging this IP with a CAPTCHA{detail}. "
                     "This usually clears within a few hours. In the meantime, use "
                     "'Open on MakerWorld' below to download the 3MF manually."
                 )

+ 221 - 14
backend/app/services/print_scheduler.py

@@ -474,6 +474,23 @@ class PrintScheduler:
         self._fast_check_interval = 3  # seconds
         self._power_on_wait_time = 180  # seconds to wait for printer after power on (3 min)
         self._power_on_check_interval = 10  # seconds between connection checks
+        # Printers whose class-target power-on failed, mapped to the monotonic
+        # time their cool-off expires (#2786).
+        #
+        # Without this, one printer with an unreachable plug starves every
+        # sibling of its model forever: the wake step walks candidates in id
+        # order, spends the pass's single attempt on the same broken printer
+        # every time, and the healthy one two slots down is never reached. It
+        # also costs a full ``_power_on_wait_time`` out of every 30 s pass,
+        # which delays the whole queue, not just this job.
+        #
+        # Entries expire on read rather than being cleared on success: a printer
+        # inside its cool-off is skipped before the power-on is reached, so a
+        # live entry can never be overwritten by a success anyway. A printer
+        # that comes back by any other route stops being a wake candidate the
+        # moment it connects.
+        self._wake_failures: dict[int, float] = {}
+        self._wake_failure_cooloff = 600  # seconds
         # Track which printers are currently auto-drying (printer_id -> start timestamp)
         self._drying_in_progress: dict[int, float] = {}
         # Defensive in-memory dispatch hold (#1157): a printer that just received
@@ -739,6 +756,17 @@ class PrintScheduler:
                 logger.warning("Home Assistant interlock check failed: %s", e)
                 interlocked = {}
 
+            # Printers a smart plug can bring back, read once for the whole pass
+            # (#2786). Used by the model-based branch both to word "Offline" in
+            # the waiting reason and to decide what the wake step may switch on.
+            wakeable_printer_ids = await self._wakeable_printer_ids(db)
+
+            # At most one power-on per queue check. Each one blocks this loop
+            # for the boot wait, so a queue of ten class-targeted jobs must not
+            # switch on ten printers inside a single pass — the next pass wakes
+            # the next one (#2786).
+            power_on_attempted = False
+
             # Log skip reasons once per queue check (not per item)
             skip_reasons: dict[str, int] = {}
 
@@ -962,6 +990,11 @@ class PrintScheduler:
                     printer_id = None
                     chosen: _ModelCandidate | None = None
                     per_model_reasons: list[tuple[str | None, str]] = []
+                    # Candidates that cleared the cross-model gate below. The
+                    # smart-plug wake step may only consider these — waking a
+                    # printer for a file that can never legally run on it is
+                    # worse than not waking at all (#2786).
+                    wakeable_candidates: list[_ModelCandidate] = []
 
                     if not candidates:
                         # Every candidate file has been deleted or trashed out from
@@ -1015,6 +1048,7 @@ class PrintScheduler:
                             skip_reasons["sliced_model_mismatch"] = skip_reasons.get("sliced_model_mismatch", 0) + 1
                             continue
 
+                        wakeable_candidates.append(candidate)
                         match_id, match_reason = await self._find_idle_printer_for_model(
                             db,
                             candidate.target_model,
@@ -1026,6 +1060,7 @@ class PrintScheduler:
                             item.target_location,
                             filament_overrides=filament_overrides,
                             require_plate_clear=require_plate_clear,
+                            wakeable_ids=wakeable_printer_ids,
                         )
                         if match_id:
                             printer_id = match_id
@@ -1033,6 +1068,34 @@ class PrintScheduler:
                             break
                         per_model_reasons.append((candidate.target_model, match_reason or ""))
 
+                    # Nothing is available and nothing has been woken this pass:
+                    # switch one matching printer on. Assignment is left to the
+                    # next pass, which sees the booted printer's live state
+                    # instead of guessing at it seconds after connect (#2786).
+                    if printer_id is None and not power_on_attempted and wakeable_candidates:
+                        woken_id, attempted_id = await self._wake_printer_for_model(
+                            db,
+                            wakeable_candidates,
+                            item.target_location,
+                            busy_printers | interlocked.keys(),
+                            wakeable_printer_ids,
+                            require_plate_clear,
+                        )
+                        # An attempt spends the pass's one wake whether or not
+                        # it worked: it has already blocked the queue loop for
+                        # the boot wait. A failed printer is held out of later
+                        # passes by its own cool-off, deliberately NOT by
+                        # busy_printers — it is off, not busy, and labelling it
+                        # busy would both misdescribe it in every later item's
+                        # waiting reason and suppress the notification, since
+                        # an all-busy reason is treated as needing no action.
+                        power_on_attempted = attempted_id is not None
+                        if woken_id is not None:
+                            # Hold this item back rather than dispatching onto a
+                            # printer whose AMS has not reported yet.
+                            skip_reasons["powered_on_printer"] = skip_reasons.get("powered_on_printer", 0) + 1
+                            continue
+
                     waiting_reason = None if printer_id else _collapse_waiting_reasons(per_model_reasons)
 
                     # Fold the winning variant's file and settings onto the item
@@ -1414,6 +1477,149 @@ class PrintScheduler:
                     return
                 await asyncio.sleep(0.5 * attempt)
 
+    async def _printers_for_model(
+        self,
+        db: AsyncSession,
+        model: str,
+        target_location: str | None = None,
+    ) -> list[Printer]:
+        """Active printers of *model*, optionally narrowed to one location.
+
+        Shared by the matcher and by the smart-plug wake step (#2786) so both
+        answer "which printers can this job run on" from one query — a job can
+        only be woken onto a printer the matcher would also have considered.
+        """
+        normalized_model = normalize_printer_model(model) or model
+        query = (
+            select(Printer)
+            .where(func.lower(Printer.model) == normalized_model.lower())
+            .where(Printer.is_active == True)  # noqa: E712
+        )
+        if target_location:
+            query = query.where(Printer.location == target_location)
+        result = await db.execute(query)
+        return list(result.scalars().all())
+
+    async def _wakeable_printer_ids(self, db: AsyncSession) -> set[int]:
+        """Printer IDs that at least one enabled ``auto_on`` plug can power on.
+
+        Read once per queue check rather than per printer: it decides both
+        whether the wake step has anything to do and how an offline printer is
+        worded in the waiting reason — "Offline" and "offline with no Auto On
+        plug" are different problems, and the second is the one the user has to
+        fix themselves (#2786).
+        """
+        result = await db.execute(
+            select(SmartPlug.printer_id)
+            .where(SmartPlug.printer_id.is_not(None))
+            .where(SmartPlug.enabled == True)  # noqa: E712
+            .where(SmartPlug.auto_on == True)  # noqa: E712
+        )
+        return {pid for (pid,) in result.all() if pid is not None}
+
+    def _wake_recently_failed(self, printer_id: int) -> bool:
+        """True while this printer's failed power-on is still cooling off (#2786)."""
+        deadline = self._wake_failures.get(printer_id)
+        if deadline is None:
+            return False
+        if time.monotonic() >= deadline:
+            del self._wake_failures[printer_id]
+            return False
+        return True
+
+    async def _wake_printer_for_model(
+        self,
+        db: AsyncSession,
+        candidates: list[_ModelCandidate],
+        target_location: str | None,
+        exclude_ids: set[int],
+        wakeable_ids: set[int],
+        require_plate_clear: bool,
+    ) -> tuple[int | None, int | None]:
+        """Power on one offline printer a model-based item could run on (#2786).
+
+        The fixed-printer branch has powered a printer on since smart plugs
+        existed. The model-based branch never could: its matcher drops an
+        offline printer into the "Offline:" waiting reason and nothing looks at
+        its plugs, so a class-targeted job with every matching printer switched
+        off sat pending forever. The reporter's log is the controlled
+        experiment — the same item, same plug, same Auto On setting, dispatched
+        the moment they edited it onto a specific printer.
+
+        Returns ``(woken_id, attempted_id)``. ``attempted_id`` is set whenever a
+        power-on was actually tried, so the caller can tell "nothing here was
+        wakeable" (both None — cheap, other items may still find something)
+        from "we tried and it did not come up" (only ``attempted_id`` — the
+        boot timeout has already been spent).
+
+        Deliberately does NOT go on to match the job: AMS trays arrive with the
+        first status push after connect, so a filament check against a printer
+        that booted seconds ago can reject the printer we just woke. The next
+        queue pass matches it with live state.
+
+        At most one printer per pass. Each wake blocks the queue loop for the
+        boot wait, and a queue of ten class-targeted jobs must not switch on
+        ten printers inside one check.
+        """
+        for candidate in candidates:
+            if not candidate.target_model:
+                continue
+            printers = await self._printers_for_model(db, candidate.target_model, target_location)
+            for printer in sorted(printers, key=lambda p: p.id):
+                if printer.id in exclude_ids or printer.id not in wakeable_ids:
+                    continue
+                if printer_manager.is_connected(printer.id):
+                    continue
+                if self._wake_recently_failed(printer.id):
+                    # Its plug did not bring it back a moment ago. Move on to a
+                    # sibling instead of spending this pass — and every pass —
+                    # on the same printer.
+                    continue
+                if require_plate_clear and printer_manager.is_awaiting_plate_clear(printer.id):
+                    # Waking this one buys nothing: it would boot into IDLE and
+                    # then be held by the plate-clear gate, which is exactly
+                    # what the reporter's log shows happening for 80 minutes
+                    # after a fixed-printer wake. The flag is Bambuddy-side and
+                    # persisted, so it is readable while the printer is off.
+                    logger.info(
+                        "Not powering on printer %s for a %s job: it is awaiting plate-clear acknowledgment",
+                        printer.id,
+                        candidate.target_model,
+                    )
+                    continue
+
+                plugs = await self._get_smart_plugs(db, printer.id)
+                auto_on_plugs = [p for p in plugs if p.auto_on and p.enabled]
+                if not auto_on_plugs:
+                    # wakeable_ids said otherwise — the plug changed under us
+                    # mid-pass. Nothing to do but move on.
+                    continue
+
+                logger.info(
+                    "No %s printer available for a queued job; powering on offline printer %s via smart plug(s)",
+                    candidate.target_model,
+                    printer.id,
+                )
+                primary_plug = self._pick_power_plug(auto_on_plugs)
+                if not await self._power_on_and_wait(primary_plug, printer.id, db):
+                    logger.warning(
+                        "Could not power on printer %s via smart plug; not trying it again for %ss",
+                        printer.id,
+                        self._wake_failure_cooloff,
+                    )
+                    self._wake_failures[printer.id] = time.monotonic() + self._wake_failure_cooloff
+                    return None, printer.id
+
+                for extra_plug in [p for p in auto_on_plugs if p.id != primary_plug.id]:
+                    try:
+                        service = await smart_plug_manager.get_service_for_plug(extra_plug, db)
+                        await service.turn_on(extra_plug)
+                        logger.info("Also powered on plug '%s' for printer %s", extra_plug.name, printer.id)
+                    except Exception as e:
+                        logger.warning("Failed to power on extra plug '%s': %s", extra_plug.name, e)
+                return printer.id, printer.id
+        return None, None
+
     async def _find_idle_printer_for_model(
         self,
         db: AsyncSession,
@@ -1423,6 +1629,7 @@ class PrintScheduler:
         target_location: str | None = None,
         filament_overrides: list[dict] | None = None,
         require_plate_clear: bool = True,
+        wakeable_ids: set[int] | None = None,
     ) -> tuple[int | None, str | None]:
         """Find an idle, connected printer matching the model with compatible filaments.
 
@@ -1437,26 +1644,17 @@ class PrintScheduler:
                                  ``force_color_match: true`` to require an exact type+color match
                                  on the printer for that slot. Without the flag the existing
                                  colour-preference logic applies.
+            wakeable_ids: Printers a smart plug can power on (#2786). Only changes how an
+                          offline printer is worded: one Bambuddy will switch on reads
+                          differently from one the user has to go and switch on themselves.
 
         Returns:
             Tuple of (printer_id, waiting_reason):
             - (printer_id, None) if a matching printer was found
             - (None, reason) if no printer is available, with explanation
         """
-        # Normalize model name and use case-insensitive matching
         normalized_model = normalize_printer_model(model) or model
-        query = (
-            select(Printer)
-            .where(func.lower(Printer.model) == normalized_model.lower())
-            .where(Printer.is_active == True)  # noqa: E712
-        )
-
-        # Add location filter if specified
-        if target_location:
-            query = query.where(Printer.location == target_location)
-
-        result = await db.execute(query)
-        printers = list(result.scalars().all())
+        printers = await self._printers_for_model(db, model, target_location)
 
         location_suffix = f" in {target_location}" if target_location else ""
         if not printers:
@@ -1469,6 +1667,7 @@ class PrintScheduler:
         # Track reasons for skipping printers
         printers_busy = []
         printers_offline = []
+        printers_offline_no_plug = []
         printers_missing_filament: list[tuple[str, list[str]]] = []
         candidates: list[tuple[int, int]] = []  # (printer_id, color_match_count)
 
@@ -1490,7 +1689,10 @@ class PrintScheduler:
             is_idle = self._is_printer_idle(printer.id, require_plate_clear) if is_connected else False
 
             if not is_connected:
-                printers_offline.append(printer.name)
+                if wakeable_ids is not None and printer.id not in wakeable_ids:
+                    printers_offline_no_plug.append(printer.name)
+                else:
+                    printers_offline.append(printer.name)
                 continue
 
             if not is_idle:
@@ -1590,6 +1792,11 @@ class PrintScheduler:
             reasons.append(f"Busy: {', '.join(printers_busy)}")
         if printers_offline:
             reasons.append(f"Offline: {', '.join(printers_offline)}")
+        if printers_offline_no_plug:
+            # Named separately because it is the one entry on this list the
+            # user has to act on: no enabled Auto On plug means Bambuddy will
+            # never power this printer on for the queue (#2786).
+            reasons.append(f"Offline, no Auto On smart plug: {', '.join(printers_offline_no_plug)}")
 
         return None, " | ".join(reasons) if reasons else f"No available {model} printers{location_suffix}"
 

+ 63 - 3
backend/app/services/printer_diagnostic.py

@@ -13,12 +13,14 @@ import asyncio
 import ipaddress
 import logging
 import socket
+import ssl
 
 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.ftp_profiles import get_ftp_profile
 from backend.app.services.printer_manager import printer_manager
 from backend.app.utils.printer_models import has_external_storage, has_remote_storage_toggle
 
@@ -63,6 +65,56 @@ async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT)
 check_port = _check_port
 
 
+async def _check_ftps_tls(ip: str, model: str | None, timeout: float = _PORT_PROBE_TIMEOUT) -> str:
+    """Probe port 990 the way the FTP client does, and say how far it got.
+
+    Returns ``"ok"``, ``"closed"`` (nothing accepted the TCP connection) or
+    ``"no_tls"`` (the port accepted the connection but the TLS handshake did
+    not complete).
+
+    A plain TCP probe cannot tell the last two apart, which is exactly how
+    #2780 hid: the reporter's diagnostic reported port 990 as reachable and
+    green while every real transfer died in the handshake with
+    ``WRONG_VERSION_NUMBER``, so archives quietly arrived empty with nothing
+    on screen to explain it.
+
+    The context mirrors :class:`~backend.app.services.bambu_ftp.ImplicitFTP_TLS`
+    -- including the model's TLS cap -- so a pass here means the FTP client
+    would also get through. Handshake only; no login is attempted, so this
+    stays valid for the pre-save Add-Printer flow where no access code exists
+    yet.
+    """
+    context = ssl.create_default_context()
+    context.check_hostname = False
+    context.verify_mode = ssl.CERT_NONE
+    context.minimum_version = ssl.TLSVersion.TLSv1_2
+    if get_ftp_profile(model).cap_tls_v1_2:
+        context.maximum_version = ssl.TLSVersion.TLSv1_2
+
+    writer = None
+    try:
+        _reader, writer = await asyncio.wait_for(
+            asyncio.open_connection(ip, PORT_FTPS, ssl=context),
+            timeout=timeout,
+        )
+        return "ok"
+    except ssl.SSLError:
+        # The socket was accepted and then failed to negotiate TLS. Reaching
+        # here at all proves something is listening, so this is never "port
+        # blocked" -- it is the printer's file service in a state no retry
+        # gets past.
+        return "no_tls"
+    except Exception:
+        return "closed"
+    finally:
+        if writer is not None:
+            writer.close()
+            try:
+                await writer.wait_closed()
+            except Exception:
+                pass
+
+
 def _auth_reason_params(reason: str | None) -> dict:
     """Map a client's CONNACK-refusal slug onto the check's `params.reason`.
 
@@ -156,14 +208,22 @@ async def run_connection_diagnostic(
 
     # --- Port reachability (probed in parallel) ---
     camera_port, camera_protocol = _camera_port_for_printer(printer)
-    mqtt_ok, ftps_ok, camera_ok = await asyncio.gather(
+    mqtt_ok, ftps_state, camera_ok = await asyncio.gather(
         _check_port(ip_address, PORT_MQTT),
-        _check_port(ip_address, PORT_FTPS),
+        _check_ftps_tls(ip_address, getattr(printer, "model", None) if printer else None),
         _check_port(ip_address, camera_port),
     )
     # MQTT is connection-critical; FTPS/camera only degrade printing/camera.
     checks.append(DiagnosticCheck(id="port_mqtt", status="pass" if mqtt_ok else "fail"))
-    checks.append(DiagnosticCheck(id="port_ftps", status="pass" if ftps_ok else "warn"))
+    # "no_tls" gets its own message: the port is open, so the usual advice
+    # (unblock port 990) is wrong and only a printer restart helps (#2780).
+    checks.append(
+        DiagnosticCheck(
+            id="port_ftps",
+            status="pass" if ftps_state == "ok" else "warn",
+            params={} if ftps_state != "no_tls" else {"reason": "no_tls"},
+        )
+    )
     checks.append(
         DiagnosticCheck(
             id="port_rtsps",

+ 32 - 0
backend/tests/integration/test_printers_api.py

@@ -4167,3 +4167,35 @@ class TestExtruderJogAPI:
         assert response.status_code == 200
         sent = mock_client.send_gcode.call_args.args[0]
         assert "E-3.50" in sent
+
+
+class TestCoverWhenFileServiceIsWedged:
+    """The cover endpoint must not report a wedged printer as "no cover".
+
+    #2780: once a printer stops completing the FTPS handshake, every cover
+    request walked all 16 candidate paths three times over and ended in a 404
+    that read as "this print has no thumbnail" — the opposite of the truth.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_503_naming_the_file_service(self, async_client: AsyncClient, printer_factory, db_session):
+        printer = await printer_factory(name="Wedged P2S")
+        state = MagicMock(subtask_name="job", state="RUNNING", gcode_file=None)
+
+        with (
+            patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=state),
+            patch("backend.app.api.routes.printers.resolve_plate_id", return_value=1),
+            patch("backend.app.api.routes.printers.get_cached_3mf", return_value=None),
+            patch("backend.app.api.routes.printers.ftps_handshake_blocked", return_value=True),
+            patch(
+                "backend.app.api.routes.printers.download_file_try_paths_async",
+                new=AsyncMock(return_value=False),
+            ) as mock_download,
+        ):
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
+
+        assert response.status_code == 503, response.text
+        assert "TLS" in response.json()["detail"]
+        # The whole point: no FTP fan-out against a printer that cannot answer.
+        mock_download.assert_not_called()

+ 46 - 0
backend/tests/integration/test_timelapse_scan_session.py

@@ -142,3 +142,49 @@ async def test_scan_timelapse_attaches_and_persists_via_fresh_session(
     assert archive.timelapse_path.endswith("test_print.gcode.mp4")
     # And the bytes actually landed on disk under the staged archive dir.
     assert (archive_dir / "test_print.gcode.mp4").read_bytes() == video_bytes
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_scan_timelapse_reports_a_wedged_file_service_as_503(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """A printer that cannot negotiate TLS is named as such, not as a 500.
+
+    #2780's reporter triggered this scan to reproduce their problem and got
+    HTTP 500 with "Failed to connect to printer or no timelapse directory
+    found" — one message for two unrelated causes, neither of which pointed at
+    the printer's file service being wedged.
+    """
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id, filename="test_print.gcode.3mf")
+
+    with (
+        patch("backend.app.services.bambu_ftp.ftps_handshake_blocked", return_value=True),
+        patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=[])) as mock_list,
+    ):
+        response = await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/scan")
+
+    assert response.status_code == 503, response.text
+    assert "TLS" in response.json()["detail"]
+    # And we did not walk all four candidate directories to find that out.
+    mock_list.assert_not_called()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_scan_timelapse_reports_a_missing_directory_as_404(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """A reachable printer with no timelapse directory is a 404, not a 500."""
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id, filename="test_print.gcode.3mf")
+
+    with (
+        patch("backend.app.services.bambu_ftp.ftps_handshake_blocked", return_value=False),
+        patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=[])),
+    ):
+        response = await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/scan")
+
+    assert response.status_code == 404, response.text
+    assert "timelapse" in response.json()["detail"].lower()

+ 8 - 1
backend/tests/unit/services/conftest.py

@@ -119,10 +119,17 @@ def ftp_client_factory(ftp_server):
 
 @pytest.fixture(autouse=True)
 def clear_ftp_mode_cache():
-    """Clear BambuFTPClient mode cache before and after each test."""
+    """Clear BambuFTPClient's per-printer caches before and after each test.
+
+    Both are class-level dicts keyed by IP, and every test here talks to
+    127.0.0.1 — a handshake cool-off left behind by one test would make the
+    next one's ``connect()`` return False without touching the server (#2780).
+    """
     BambuFTPClient._mode_cache.clear()
+    BambuFTPClient._handshake_blocked_until.clear()
     yield
     BambuFTPClient._mode_cache.clear()
+    BambuFTPClient._handshake_blocked_until.clear()
 
 
 @pytest.fixture()

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

@@ -14,6 +14,8 @@ Tests against a real mock implicit FTPS server, covering:
 """
 
 import asyncio
+import logging
+import socket
 import threading
 import time
 from pathlib import Path
@@ -1615,3 +1617,113 @@ class TestUploadDeadline:
 
         time.sleep(_UPLOAD_FLUSH_DELAY)
         assert not (Path(ftp_root) / "cancelme.3mf").exists(), "partial file left on the printer"
+
+
+# ---------------------------------------------------------------------------
+# TestHandshakeCoolOff
+# ---------------------------------------------------------------------------
+class _PlaintextServer:
+    """A socket that accepts on 990 and answers in plaintext, not TLS.
+
+    This is what #2780's printers do once their file service wedges: the TCP
+    connect succeeds, so a port probe reports the printer as healthy, and then
+    the implicit-FTPS handshake dies on ``WRONG_VERSION_NUMBER`` because the
+    first bytes back are an FTP banner rather than a TLS record.
+    """
+
+    def __init__(self):
+        self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+        self._sock.bind(("127.0.0.1", 0))
+        self._sock.listen(8)
+        self.port = self._sock.getsockname()[1]
+        self.accepts = 0
+        self._stop = threading.Event()
+        self._thread = threading.Thread(target=self._serve, daemon=True)
+        self._thread.start()
+
+    def _serve(self):
+        while not self._stop.is_set():
+            try:
+                conn, _addr = self._sock.accept()
+            except OSError:
+                return
+            self.accepts += 1
+            try:
+                conn.sendall(b"220 Welcome to the printer.\r\n")
+            except OSError:
+                pass
+            finally:
+                conn.close()
+
+    def stop(self):
+        self._stop.set()
+        self._sock.close()
+        self._thread.join(timeout=2)
+
+
+@pytest.fixture()
+def plaintext_server():
+    server = _PlaintextServer()
+    yield server
+    server.stop()
+
+
+class TestHandshakeCoolOff:
+    """A wedged file service must be contacted once, not hundreds of times.
+
+    #2780: an X2D served clean FTPS for five days, flipped, and then failed
+    every handshake for eight more. Because each candidate path opened its own
+    connection, one reporter's log carried 3511 identical handshake failures.
+    """
+
+    def _client(self, server, ip="127.0.0.1"):
+        client = BambuFTPClient(ip, "12345678", timeout=5.0, printer_model="P2S")
+        client.FTP_PORT = server.port
+        return client
+
+    def test_plaintext_answer_on_990_blocks_the_printer(self, plaintext_server, caplog):
+        client = self._client(plaintext_server)
+        with caplog.at_level(logging.WARNING, logger="backend.app.services.bambu_ftp"):
+            assert client.connect() is False
+        assert bambu_ftp.ftps_handshake_blocked("127.0.0.1") is True
+        messages = [r.getMessage() for r in caplog.records]
+        assert any("WRONG_VERSION_NUMBER" in m for m in messages)
+        # The warning has to name the remedy, not just the error: the port is
+        # open, so "unblock port 990" is the wrong advice.
+        assert any("restarted" in m for m in messages)
+
+    def test_blocked_printer_is_not_contacted_again(self, plaintext_server):
+        self._client(plaintext_server).connect()
+        assert plaintext_server.accepts == 1
+
+        for _ in range(5):
+            assert self._client(plaintext_server).connect() is False
+        # Still one: the cool-off answered without opening a socket.
+        assert plaintext_server.accepts == 1
+
+    def test_cooloff_expiry_lets_the_printer_be_retried(self, plaintext_server, monkeypatch):
+        monkeypatch.setattr(bambu_ftp, "_HANDSHAKE_COOLOFF_SECONDS", 0.0)
+        self._client(plaintext_server).connect()
+        assert bambu_ftp.ftps_handshake_blocked("127.0.0.1") is False
+        assert self._client(plaintext_server).connect() is False
+        assert plaintext_server.accepts == 2
+
+    def test_block_is_per_printer(self, plaintext_server):
+        self._client(plaintext_server).connect()
+        assert bambu_ftp.ftps_handshake_blocked("127.0.0.1") is True
+        # A second printer that never failed must stay reachable.
+        assert bambu_ftp.ftps_handshake_blocked("192.0.2.77") is False
+
+    def test_expired_block_lets_a_recovered_printer_straight_back_in(self, ftp_client_factory):
+        """A power-cycled printer is picked up on the next print, not held out.
+
+        The expired entry is also dropped, so the map holds one key per
+        currently-wedged printer rather than one per printer this process has
+        ever failed against.
+        """
+        BambuFTPClient._handshake_blocked_until["127.0.0.1"] = time.monotonic() - 1
+        client = ftp_client_factory()
+        assert client.connect() is True
+        client.disconnect()
+        assert BambuFTPClient._handshake_blocked_until == {}

+ 97 - 4
backend/tests/unit/services/test_printer_diagnostic.py

@@ -5,11 +5,16 @@ drive the localized fix text the user sees when a printer won't connect,
 so a status flip is a user-facing regression — each one is asserted here.
 """
 
+import ssl
 import types
 from contextlib import ExitStack
 from unittest.mock import AsyncMock, MagicMock, patch
 
-from backend.app.services.printer_diagnostic import _same_subnet, run_connection_diagnostic
+from backend.app.services.printer_diagnostic import (
+    _check_ftps_tls,
+    _same_subnet,
+    run_connection_diagnostic,
+)
 
 MOD = "backend.app.services.printer_diagnostic"
 
@@ -20,8 +25,13 @@ def _statuses(result):
 
 
 def _port_probe(overrides=None):
-    """Sync side_effect for _check_port. Defaults: every port reachable."""
-    reachable = {8883: True, 990: True, 322: True, 6000: True}
+    """Sync side_effect for _check_port. Defaults: every port reachable.
+
+    990 is absent: the FTPS check runs a real TLS handshake through
+    ``_check_ftps_tls`` rather than a bare TCP probe, and ``_Env(ftps=...)``
+    drives it.
+    """
+    reachable = {8883: True, 322: True, 6000: True}
     reachable.update(overrides or {})
 
     def _probe(ip, port, timeout=3.0):
@@ -45,6 +55,7 @@ class _Env:
         self,
         *,
         ports=None,
+        ftps="ok",
         in_docker=True,
         network_mode="host",
         host_ip="192.168.1.5",
@@ -54,6 +65,8 @@ class _Env:
         connect_error: str | None = None,
     ):
         self.ports = ports or _port_probe()
+        # What the FTPS probe reports: "ok", "closed" or "no_tls" (#2780).
+        self.ftps = ftps
         self.in_docker = in_docker
         self.network_mode = network_mode
         self.host_ip = host_ip
@@ -84,6 +97,7 @@ class _Env:
             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}._check_ftps_tls", new_callable=AsyncMock, return_value=self.ftps))
         self._stack.enter_context(patch(f"{MOD}.is_running_in_docker", return_value=self.in_docker))
         self._stack.enter_context(patch(f"{MOD}._detect_docker_network_mode", return_value=self.network_mode))
         self._stack.enter_context(patch(f"{MOD}._get_host_ip", return_value=self.host_ip))
@@ -144,13 +158,32 @@ class TestExistingPrinter:
         assert s["mqtt_auth"] == "skip"
 
     async def test_ftps_and_rtsps_only_warn(self):
-        with _Env(ports=_port_probe({990: False, 322: False}), state=_state()):
+        with _Env(ports=_port_probe({322: False}), ftps="closed", state=_state()):
             result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
         s = _statuses(result)
         # No critical failure -> warnings, not problems.
         assert result.overall == "warnings"
         assert s["port_ftps"] == "warn"
         assert s["port_rtsps"] == "warn"
+        # Nothing was listening, so the message stays the generic "unblock the
+        # port" one — no reason variant.
+        ftps_check = next(c for c in result.checks if c.id == "port_ftps")
+        assert ftps_check.params == {}
+
+    async def test_open_port_that_cannot_negotiate_tls_says_so(self):
+        """An open 990 that fails the handshake must not read as healthy.
+
+        #2780's reporter saw port 990 green while every 3MF download died in
+        the TLS handshake, so the archives arrived empty with nothing on
+        screen to explain it. The reason variant selects a message that names
+        a printer restart instead of sending the user to their firewall.
+        """
+        with _Env(ftps="no_tls", state=_state()):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="P2S"))
+        assert _statuses(result)["port_ftps"] == "warn"
+        ftps_check = next(c for c in result.checks if c.id == "port_ftps")
+        assert ftps_check.params == {"reason": "no_tls"}
+        assert result.overall == "warnings"
 
     async def test_a1_mini_uses_chamber_image_camera_port(self):
         # A1/P1-family printers use the chamber-image camera protocol on 6000,
@@ -425,3 +458,63 @@ class TestExternalStorageCheck:
         with _Env(state=_state(store_to_sdcard=True)):
             result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="P1S"))
         assert _statuses(result)["external_storage"] == "pass"
+
+
+class TestFtpsTlsProbe:
+    """The FTPS probe must reach the handshake, not stop at the TCP accept.
+
+    #2780: a printer whose file service stops answering with TLS still
+    accepts the connection on 990, so the old bare TCP probe reported it
+    green while every archive came back empty.
+    """
+
+    async def test_completed_handshake_is_ok(self):
+        writer = MagicMock()
+        writer.wait_closed = AsyncMock()
+        with patch(f"{MOD}.asyncio.open_connection", new_callable=AsyncMock, return_value=(MagicMock(), writer)):
+            assert await _check_ftps_tls("192.168.1.50", "X1C") == "ok"
+        writer.close.assert_called_once()
+
+    async def test_handshake_failure_on_an_open_port_is_no_tls(self):
+        with patch(
+            f"{MOD}.asyncio.open_connection",
+            new_callable=AsyncMock,
+            side_effect=ssl.SSLError("[SSL: WRONG_VERSION_NUMBER] wrong version number"),
+        ):
+            assert await _check_ftps_tls("192.168.1.50", "P2S") == "no_tls"
+
+    async def test_refused_connection_is_closed(self):
+        with patch(f"{MOD}.asyncio.open_connection", new_callable=AsyncMock, side_effect=ConnectionRefusedError):
+            assert await _check_ftps_tls("192.168.1.50", "X1C") == "closed"
+
+    async def test_timeout_is_closed_not_no_tls(self):
+        # A printer that is switched off never gets far enough to say anything
+        # about TLS — that has to stay the generic "port unreachable" advice.
+        with patch(f"{MOD}.asyncio.open_connection", new_callable=AsyncMock, side_effect=TimeoutError):
+            assert await _check_ftps_tls("192.168.1.50", "X1C") == "closed"
+
+    async def test_probe_mirrors_the_model_tls_cap(self):
+        """The probe must negotiate exactly what the FTP client negotiates.
+
+        A P2S is pinned to TLS 1.2 by its ftp_profiles entry; probing it on a
+        context that also offers 1.3 could pass where the real transfer fails
+        (or the reverse), which is the class of false green this check exists
+        to remove.
+        """
+        contexts = []
+
+        async def _capture(host, port, ssl=None):
+            contexts.append(ssl)
+            writer = MagicMock()
+            writer.wait_closed = AsyncMock()
+            return MagicMock(), writer
+
+        with patch(f"{MOD}.asyncio.open_connection", new=_capture):
+            await _check_ftps_tls("192.168.1.50", "P2S")
+            await _check_ftps_tls("192.168.1.50", "X1C")
+
+        capped, uncapped = contexts
+        assert capped.maximum_version == ssl.TLSVersion.TLSv1_2
+        assert capped.minimum_version == ssl.TLSVersion.TLSv1_2
+        assert uncapped.maximum_version != ssl.TLSVersion.TLSv1_2
+        assert uncapped.minimum_version == ssl.TLSVersion.TLSv1_2

+ 242 - 0
backend/tests/unit/test_cloud_captcha_2790.py

@@ -0,0 +1,242 @@
+"""Tests for Bambu's anti-abuse CAPTCHA challenge on sign-in (#2790).
+
+Bambu's own anti-abuse layer -- not the Cloudflare edge -- answers a request it
+has flagged with ``HTTP 418`` and ``{"captchaId": ..., "error": "We need you to
+confirm you are not a robot"}``. It is keyed to the source IP, no credential
+will be accepted until it clears, and there is no server-side solve.
+
+That body is well-formed JSON, so the Cloudflare detector never fired on it and
+``login_request`` fell through to its generic error path, which lifted Bambu's
+sentence out of ``error`` and returned it verbatim. The reporter got a bare
+toast reading "We need you to confirm you are not a robot" -- no challenge to
+answer, no explanation, nothing to click -- and filed it as a Bambuddy bug.
+
+These tests pin: the challenge is recognised by shape rather than by wording,
+all three sign-in calls report it as ``reason="captcha"`` with an explanation
+instead of Bambu's raw string, retries are held back per-origin so Bambuddy
+stops deepening the block, and the scanner names it in the next support bundle.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import httpx
+import pytest
+
+from backend.app.services import bambu_cloud as bc
+from backend.app.services.bambu_cloud import BambuCloudService
+
+# Bambu's actual challenge body, as seen on both the login endpoint and the
+# design-service endpoints MakerWorld imports use.
+_CAPTCHA_BODY = {
+    "captchaId": "3f2a9c1e64b04d7f",
+    "error": "We need you to confirm you are not a robot",
+}
+
+
+@pytest.fixture(autouse=True)
+def _clear_captcha_cooloff():
+    """The cool-off map is module-level; don't leak it across tests."""
+    bc._captcha_blocked_until.clear()
+    yield
+    bc._captcha_blocked_until.clear()
+
+
+def _response(status_code: int, body: object | None = None, *, text: str | None = None):
+    resp = MagicMock()
+    resp.status_code = status_code
+    if body is None and text is not None:
+        resp.json = MagicMock(side_effect=ValueError("not json"))
+    else:
+        resp.json = MagicMock(return_value=body if body is not None else {})
+    resp.text = text if text is not None else "{}"
+    return resp
+
+
+def _service(response) -> BambuCloudService:
+    svc = BambuCloudService(client=MagicMock(spec=httpx.AsyncClient))
+    svc._client.post = AsyncMock(return_value=response)
+    svc._client.get = AsyncMock(return_value=response)
+    return svc
+
+
+class TestChallengeIsRecognisedByShape:
+    def test_captcha_id_marks_the_challenge(self):
+        assert bc.is_captcha_challenge(_response(418, _CAPTCHA_BODY)) is True
+
+    def test_wording_alone_is_enough(self):
+        """No captchaId, but the text says what it is. Bambu has shipped the
+        challenge under more than one body shape."""
+        assert bc.is_captcha_challenge(_response(418, {"error": "please confirm you are not a robot"})) is True
+
+    def test_a_418_without_a_marker_is_not_reported_as_a_captcha(self):
+        """Telling a user to solve a CAPTCHA that was never offered is the exact
+        confusion this issue is about -- don't invent one for any stray 418."""
+        assert bc.is_captcha_challenge(_response(418, {"error": "Too many requests"})) is False
+
+    def test_status_alone_does_not_decide_it(self):
+        """A captchaId on a 200 is not a refusal -- only the 418 is."""
+        assert bc.is_captcha_challenge(_response(200, _CAPTCHA_BODY)) is False
+
+    def test_a_non_json_challenge_is_still_recognised(self):
+        resp = _response(418, None, text="<html><body>captcha required</body></html>")
+        assert bc.is_captcha_challenge(resp) is True
+
+    def test_a_non_json_body_without_markers_is_not(self):
+        resp = _response(418, None, text="<html><body>Service unavailable</body></html>")
+        assert bc.is_captcha_challenge(resp) is False
+
+
+class TestSignInReportsTheChallenge:
+    @pytest.mark.asyncio
+    async def test_login_explains_instead_of_echoing_bambu(self):
+        svc = _service(_response(418, _CAPTCHA_BODY))
+
+        result = await svc.login_request("user@example.com", "pw")
+
+        assert result["success"] is False
+        assert result["needs_verification"] is False
+        assert result["reason"] == "captcha"
+        # The regression in one line: this used to BE Bambu's sentence.
+        assert result["message"] != _CAPTCHA_BODY["error"]
+        assert "CAPTCHA" in result["message"]
+        # The two things the reporter had no way to know.
+        assert "password" in result["message"].lower()
+        assert "access token" in result["message"].lower()
+
+    @pytest.mark.asyncio
+    async def test_email_code_verification_reports_it_too(self):
+        svc = _service(_response(418, _CAPTCHA_BODY))
+
+        result = await svc.verify_code("user@example.com", "123456")
+
+        assert result["reason"] == "captcha"
+        assert result["message"] != _CAPTCHA_BODY["error"]
+
+    @pytest.mark.asyncio
+    async def test_totp_verification_reports_it_too(self):
+        svc = _service(_response(418, _CAPTCHA_BODY))
+        with patch.object(svc, "_fetch_csrf_token", AsyncMock(return_value="csrf-token")):
+            result = await svc.verify_totp("tfa-key", "123456")
+
+        assert result["reason"] == "captcha"
+        assert result["message"] != _CAPTCHA_BODY["error"]
+
+    @pytest.mark.asyncio
+    async def test_an_ordinary_rejection_is_unchanged(self):
+        """Wrong password still says what Bambu said, and carries no reason --
+        the UI must keep toasting those rather than showing the CAPTCHA panel."""
+        svc = _service(_response(400, {"error": "Login failed"}))
+
+        result = await svc.login_request("user@example.com", "wrong")
+
+        assert result["message"] == "Login failed"
+        assert result.get("reason") is None
+        assert not bc.captcha_cooloff_active(svc.base_url)
+
+
+class TestRetriesAreHeldBack:
+    @pytest.mark.asyncio
+    async def test_a_second_attempt_is_not_sent_to_bambu(self):
+        """The reporter's log shows four attempts in eighteen seconds. Every one
+        of them is more evidence for the thing that flagged us."""
+        svc = _service(_response(418, _CAPTCHA_BODY))
+        await svc.login_request("user@example.com", "pw")
+        assert svc._client.post.await_count == 1
+
+        result = await svc.login_request("user@example.com", "pw")
+
+        assert svc._client.post.await_count == 1
+        assert result["reason"] == "captcha"
+
+    @pytest.mark.asyncio
+    async def test_the_cooloff_covers_a_fresh_service_instance(self):
+        """Services are built per request, so the cool-off has to outlive one."""
+        await _service(_response(418, _CAPTCHA_BODY)).login_request("user@example.com", "pw")
+
+        second = _service(_response(200, {"loginType": "verifyCode"}))
+        result = await second.login_request("user@example.com", "pw")
+
+        second._client.post.assert_not_awaited()
+        assert result["reason"] == "captcha"
+
+    @pytest.mark.asyncio
+    async def test_the_cooloff_expires(self):
+        svc = _service(_response(418, _CAPTCHA_BODY))
+        await svc.login_request("user@example.com", "pw")
+
+        bc._captcha_blocked_until[svc.base_url] = bc.time.monotonic() - 1
+        svc._client.post = AsyncMock(return_value=_response(200, {"loginType": "verifyCode"}))
+        result = await svc.login_request("user@example.com", "pw")
+
+        assert result["needs_verification"] is True
+        assert bc._captcha_blocked_until == {}, "the expired entry should be dropped on the way past"
+
+    @pytest.mark.asyncio
+    async def test_a_challenge_on_the_api_host_does_not_strand_a_totp_sign_in(self):
+        """TOTP verification goes to bambulab.com, everything else to
+        api.bambulab.com. Blocking one on the other's behalf would leave a user
+        halfway through two-factor with no way forward."""
+        svc = _service(_response(418, _CAPTCHA_BODY))
+        await svc.login_request("user@example.com", "pw")
+
+        svc._client.post = AsyncMock(return_value=_response(200, {"accessToken": "tok"}))
+        with patch.object(svc, "_fetch_csrf_token", AsyncMock(return_value="csrf-token")):
+            result = await svc.verify_totp("tfa-key", "123456")
+
+        assert result["success"] is True
+
+    @pytest.mark.asyncio
+    async def test_the_china_region_is_tracked_separately(self):
+        """The block lives at the edge in front of one region."""
+        await _service(_response(418, _CAPTCHA_BODY)).login_request("user@example.com", "pw")
+
+        cn = BambuCloudService(region="china", client=MagicMock(spec=httpx.AsyncClient))
+        cn._client.post = AsyncMock(return_value=_response(200, {"loginType": "verifyCode"}))
+        result = await cn.login_request("user@example.com", "pw")
+
+        cn._client.post.assert_awaited_once()
+        assert result["needs_verification"] is True
+
+
+class TestMakerWorldSharesTheDetector:
+    @pytest.mark.asyncio
+    async def test_a_challenge_worded_differently_is_still_named(self):
+        """MakerWorld used to require the literal word "robot" in the error text
+        and reported anything else as an unexplained block."""
+        from backend.app.services.makerworld import MakerWorldService, MakerWorldUnavailableError
+
+        svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient), auth_token="tok")
+        svc._client.get = AsyncMock(return_value=_response(418, {"captchaId": "abc", "error": "verification required"}))
+
+        with pytest.raises(MakerWorldUnavailableError) as exc:
+            await svc._get_json("/design/1")
+
+        assert "CAPTCHA" in str(exc.value)
+        assert "Open on MakerWorld" in str(exc.value)
+
+
+class TestTheSupportBundleNamesIt:
+    def test_the_warning_we_log_matches_the_signature(self, tmp_path, monkeypatch, caplog):
+        """The reporter's bundle came back with zero log-health findings while
+        the log was full of the failure -- tie the two ends together."""
+        from backend.app.core.config import settings as app_settings
+        from backend.app.services.log_health import scan_logs
+
+        svc = _service(_response(418, _CAPTCHA_BODY))
+        with caplog.at_level("WARNING", logger="backend.app.services.bambu_cloud"):
+            svc._note_captcha(_response(418, _CAPTCHA_BODY))
+        logged = caplog.records[-1].getMessage()
+
+        log_file = tmp_path / "bambuddy.log"
+        log_file.write_text(
+            f"2026-08-08 05:15:37,068 WARNING [backend.app.services.bambu_cloud] {logged}\n",
+            encoding="utf-8",
+        )
+        monkeypatch.setattr(app_settings, "log_dir", tmp_path)
+
+        findings = scan_logs().findings
+
+        assert [f.signature_id for f in findings] == ["bambu-cloud-captcha"]
+        assert findings[0].wiki_anchor == "bambu-cloud-captcha"

+ 424 - 0
backend/tests/unit/test_scheduler_class_target_smart_plug_2786.py

@@ -0,0 +1,424 @@
+"""Smart-plug power-on for class-targeted queue items (#2786).
+
+Powering a printer on for a queued job has existed since smart plugs did, but
+only on the branch that handles an item pinned to one printer. An item queued
+as "Any X1C" carries no ``printer_id``, takes the model-based branch, and that
+branch's matcher drops an offline printer into a "Offline:" waiting reason
+without ever looking at its plugs. With every matching printer switched off the
+job sat pending indefinitely.
+
+The reporter's log is the controlled experiment: the same item, same plug, same
+Auto On setting, powered a printer on the moment they edited it onto a specific
+printer -- and did nothing for the thirteen minutes before that.
+"""
+
+from contextlib import ExitStack
+from datetime import datetime, timedelta, timezone
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+from backend.app.core.database import Base
+from backend.app.models.library import LibraryFile
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.models.smart_plug import SmartPlug
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+@pytest.fixture
+async def queue_db():
+    """Two X1Cs, each on its own plug, so "which one" is a real question."""
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    session_maker = async_sessionmaker(engine, expire_on_commit=False)
+
+    async with session_maker() as db:
+        db.add_all(
+            [
+                Printer(
+                    id=1,
+                    name="X1C-1",
+                    serial_number="X1C0001",
+                    ip_address="10.0.0.1",
+                    access_code="x",
+                    model="X1C",
+                    is_active=True,
+                ),
+                Printer(
+                    id=2,
+                    name="X1C-2",
+                    serial_number="X1C0002",
+                    ip_address="10.0.0.2",
+                    access_code="x",
+                    model="X1C",
+                    is_active=True,
+                ),
+            ]
+        )
+        await db.commit()
+
+    try:
+        yield SimpleNamespace(session_maker=session_maker)
+    finally:
+        await engine.dispose()
+
+
+async def _add_plug(ctx, printer_id, *, auto_on=True, enabled=True, name=None):
+    async with ctx.session_maker() as db:
+        plug = SmartPlug(
+            name=name or f"Plug {printer_id}",
+            plug_type="tasmota",
+            ip_address=f"10.0.1.{printer_id}",
+            printer_id=printer_id,
+            enabled=enabled,
+            auto_on=auto_on,
+        )
+        db.add(plug)
+        await db.commit()
+        return plug.id
+
+
+async def _add_item(
+    ctx, *, printer_id=None, target_model=None, sliced_for="X1C", position=1, scheduled_time=None, manual_start=False
+):
+    async with ctx.session_maker() as db:
+        lib = LibraryFile(
+            filename="job.gcode.3mf",
+            file_path="/library/job.gcode.3mf",
+            file_size=10,
+            file_type="gcode.3mf",
+            file_metadata={"sliced_for_model": sliced_for},
+        )
+        db.add(lib)
+        await db.flush()
+        item = PrintQueueItem(
+            status="pending",
+            position=position,
+            printer_id=printer_id,
+            target_model=target_model,
+            library_file_id=lib.id,
+            scheduled_time=scheduled_time,
+            manual_start=manual_start,
+        )
+        db.add(item)
+        await db.commit()
+        return item.id
+
+
+async def _run(
+    ctx,
+    scheduler,
+    *,
+    power_on=AsyncMock,
+    connected=False,
+    awaiting_plate_clear=(),
+    require_plate_clear=True,
+    launched=None,
+):
+    """Run one queue pass with every printer offline unless told otherwise.
+
+    ``power_on`` is the patched ``_power_on_and_wait``; the tests assert on the
+    printer ids it was called with, which is the whole behaviour under test.
+    """
+    power_on_mock = power_on() if isinstance(power_on, type) else power_on
+    with ExitStack() as stack:
+        for p in [
+            patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
+            patch("backend.app.core.database.async_session", ctx.session_maker),
+            patch(
+                "backend.app.services.print_scheduler.printer_manager.is_connected",
+                MagicMock(side_effect=lambda pid: pid in connected if connected else False),
+            ),
+            patch(
+                "backend.app.services.print_scheduler.printer_manager.is_awaiting_plate_clear",
+                MagicMock(side_effect=lambda pid: pid in awaiting_plate_clear),
+            ),
+            patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
+            patch(
+                "backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
+                AsyncMock(return_value={}),
+            ),
+            patch(
+                "backend.app.services.notification_service.notification_service.on_queue_job_waiting",
+                AsyncMock(),
+            ),
+            patch(
+                "backend.app.services.notification_service.notification_service.on_queue_job_assigned",
+                AsyncMock(),
+            ),
+            patch.object(scheduler, "_check_auto_drying", AsyncMock()),
+            patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
+            patch.object(scheduler, "_block_on_filament_deficit", AsyncMock(return_value=False)),
+            patch.object(scheduler, "_launch_uploads", launched or MagicMock()),
+            patch.object(scheduler, "_power_on_and_wait", power_on_mock),
+            patch.object(
+                scheduler,
+                "_get_bool_setting",
+                AsyncMock(
+                    side_effect=lambda db, key, default=False: (
+                        require_plate_clear if key == "require_plate_clear" else default
+                    )
+                ),
+            ),
+        ]:
+            stack.enter_context(p)
+        await scheduler.check_queue()
+    return power_on_mock
+
+
+async def _get_item(ctx, item_id):
+    async with ctx.session_maker() as db:
+        return (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
+
+
+def _woken_printer_ids(power_on_mock):
+    """Printer ids ``_power_on_and_wait(plug, printer_id, db)`` was called for."""
+    return [call.args[1] for call in power_on_mock.await_args_list]
+
+
+class TestClassTargetWakesAPrinter:
+    @pytest.mark.asyncio
+    async def test_offline_printers_are_powered_on_for_an_any_model_job(self, queue_db):
+        """The bug: this used to do nothing at all."""
+        await _add_plug(queue_db, 1)
+        await _add_plug(queue_db, 2)
+        item_id = await _add_item(queue_db, target_model="X1C")
+
+        power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
+
+        assert _woken_printer_ids(power_on) == [1]
+        # Assignment is left to the next pass, once the printer has reported.
+        item = await _get_item(queue_db, item_id)
+        assert item.status == "pending"
+        assert item.printer_id is None
+
+    @pytest.mark.asyncio
+    async def test_a_printer_awaiting_plate_clear_is_passed_over(self, queue_db):
+        """Waking it buys nothing -- the plate-clear gate would hold it anyway.
+
+        This is what the reporter's log shows after a fixed-printer wake: the
+        printer booted and then reported ``awaiting_plate_clear=True`` every 30
+        seconds for the next 80 minutes. The flag is Bambuddy-side and
+        persisted, so it is readable while the printer is still switched off.
+        """
+        await _add_plug(queue_db, 1)
+        await _add_plug(queue_db, 2)
+        await _add_item(queue_db, target_model="X1C")
+
+        power_on = await _run(
+            queue_db,
+            PrintScheduler(),
+            power_on=AsyncMock(return_value=True),
+            awaiting_plate_clear=(1,),
+        )
+
+        assert _woken_printer_ids(power_on) == [2]
+
+    @pytest.mark.asyncio
+    async def test_nothing_is_woken_when_every_candidate_awaits_plate_clear(self, queue_db):
+        await _add_plug(queue_db, 1)
+        await _add_plug(queue_db, 2)
+        await _add_item(queue_db, target_model="X1C")
+
+        power_on = await _run(
+            queue_db,
+            PrintScheduler(),
+            power_on=AsyncMock(return_value=True),
+            awaiting_plate_clear=(1, 2),
+        )
+
+        power_on.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_plate_clear_gate_off_wakes_anyway(self, queue_db):
+        """With the gate disabled the flag is not a reason to skip a printer."""
+        await _add_plug(queue_db, 1)
+        await _add_item(queue_db, target_model="X1C")
+
+        power_on = await _run(
+            queue_db,
+            PrintScheduler(),
+            power_on=AsyncMock(return_value=True),
+            awaiting_plate_clear=(1,),
+            require_plate_clear=False,
+        )
+
+        assert _woken_printer_ids(power_on) == [1]
+
+    @pytest.mark.asyncio
+    async def test_at_most_one_printer_is_woken_per_pass(self, queue_db):
+        """Each wake blocks the queue loop for the boot wait.
+
+        Ten class-targeted jobs must not switch on ten printers inside one
+        check; the next pass wakes the next one.
+        """
+        await _add_plug(queue_db, 1)
+        await _add_plug(queue_db, 2)
+        await _add_item(queue_db, target_model="X1C", position=1)
+        await _add_item(queue_db, target_model="X1C", position=2)
+
+        power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
+
+        assert _woken_printer_ids(power_on) == [1]
+
+    @pytest.mark.asyncio
+    async def test_a_failed_power_on_is_not_retried_in_the_same_pass(self, queue_db):
+        await _add_plug(queue_db, 1)
+        await _add_plug(queue_db, 2)
+        item_a = await _add_item(queue_db, target_model="X1C", position=1)
+        item_b = await _add_item(queue_db, target_model="X1C", position=2)
+
+        power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=False))
+
+        assert _woken_printer_ids(power_on) == [1]
+        # A printer we failed to switch on is off, not busy. Calling it busy
+        # would misdescribe it here and, because an all-busy reason is treated
+        # as needing no user action, silence the notification as well.
+        for item_id in (item_a, item_b):
+            reason = (await _get_item(queue_db, item_id)).waiting_reason or ""
+            assert "Busy" not in reason
+            assert "Offline" in reason
+
+    @pytest.mark.asyncio
+    async def test_a_dead_plug_does_not_starve_its_siblings(self, queue_db):
+        """One unreachable plug must not hold every sibling of its model hostage.
+
+        Candidates are walked in id order and a pass spends only one power-on
+        attempt, so without a cool-off the broken printer is picked again on
+        every pass and the healthy one behind it is never reached. It also
+        costs a full boot timeout out of each 30s pass, which delays the whole
+        queue rather than just this job.
+        """
+        await _add_plug(queue_db, 1)
+        await _add_plug(queue_db, 2)
+        await _add_item(queue_db, target_model="X1C")
+
+        scheduler = PrintScheduler()
+        power_on = AsyncMock(return_value=False)
+        await _run(queue_db, scheduler, power_on=power_on)
+        await _run(queue_db, scheduler, power_on=power_on)
+
+        assert _woken_printer_ids(power_on) == [1, 2]
+
+    @pytest.mark.asyncio
+    async def test_a_printer_is_tried_again_once_its_cooloff_expires(self, queue_db):
+        """The skip is a cool-off, not a blacklist — a fixed plug is picked up."""
+        await _add_plug(queue_db, 1)
+        await _add_item(queue_db, target_model="X1C")
+
+        scheduler = PrintScheduler()
+        scheduler._wake_failure_cooloff = 0
+        power_on = AsyncMock(return_value=False)
+        await _run(queue_db, scheduler, power_on=power_on)
+        await _run(queue_db, scheduler, power_on=power_on)
+
+        assert _woken_printer_ids(power_on) == [1, 1]
+
+    @pytest.mark.asyncio
+    async def test_an_expired_cooloff_is_not_left_behind(self, queue_db):
+        """The map holds one key per currently-failing printer, not per printer
+        this process has ever failed to wake."""
+        await _add_plug(queue_db, 1)
+        await _add_item(queue_db, target_model="X1C")
+
+        scheduler = PrintScheduler()
+        scheduler._wake_failure_cooloff = 0
+        await _run(queue_db, scheduler, power_on=AsyncMock(return_value=False))
+        assert 1 in scheduler._wake_failures
+
+        await _run(queue_db, scheduler, power_on=AsyncMock(return_value=True))
+        assert scheduler._wake_failures == {}
+
+
+class TestWhatIsNotWokenUp:
+    @pytest.mark.asyncio
+    async def test_a_printer_with_no_auto_on_plug_is_left_alone_and_said_so(self, queue_db):
+        """The first question asked of the reporter was whether Auto On was on.
+
+        "Offline" and "offline with no Auto On plug" are different problems and
+        only the second is one the user has to go and fix, so they must not
+        share a waiting reason.
+        """
+        await _add_plug(queue_db, 1, auto_on=False)
+        await _add_plug(queue_db, 2, enabled=False)
+        item_id = await _add_item(queue_db, target_model="X1C")
+
+        power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
+
+        power_on.assert_not_awaited()
+        item = await _get_item(queue_db, item_id)
+        assert item.waiting_reason is not None
+        assert "no Auto On smart plug" in item.waiting_reason
+        assert "X1C-1" in item.waiting_reason and "X1C-2" in item.waiting_reason
+
+    @pytest.mark.asyncio
+    async def test_an_incompatible_file_never_wakes_anything(self, queue_db):
+        """The cross-model gate (#2578) runs before the wake, not after it.
+
+        Switching a printer on for a file that can never legally run on it is
+        worse than leaving it off: the job still cannot start, and now the
+        printer is drawing power.
+        """
+        await _add_plug(queue_db, 1)
+        await _add_item(queue_db, target_model="X1C", sliced_for="A1")
+
+        power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
+
+        power_on.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_a_job_scheduled_for_later_does_not_switch_anything_on_now(self, queue_db):
+        """Otherwise a print set for 3am powers a printer up the moment it is queued."""
+        await _add_plug(queue_db, 1)
+        await _add_item(
+            queue_db,
+            target_model="X1C",
+            scheduled_time=datetime.now(timezone.utc) + timedelta(hours=6),
+        )
+
+        power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
+
+        power_on.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_a_manual_start_job_does_not_switch_anything_on(self, queue_db):
+        """Manual start means the user presses play; nothing happens until they do."""
+        await _add_plug(queue_db, 1)
+        await _add_item(queue_db, target_model="X1C", manual_start=True)
+
+        power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
+
+        power_on.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_an_already_connected_printer_is_not_powered_on(self, queue_db):
+        await _add_plug(queue_db, 1)
+        await _add_plug(queue_db, 2)
+        await _add_item(queue_db, target_model="X1C")
+
+        power_on = await _run(
+            queue_db,
+            PrintScheduler(),
+            power_on=AsyncMock(return_value=True),
+            connected=(1, 2),
+        )
+
+        power_on.assert_not_awaited()
+
+
+class TestFixedPrinterBranchStillWakes:
+    @pytest.mark.asyncio
+    async def test_an_item_pinned_to_a_printer_still_powers_it_on(self, queue_db):
+        """The branch that always worked, pinned so a refactor cannot drop it."""
+        await _add_plug(queue_db, 2)
+        await _add_item(queue_db, printer_id=2)
+
+        power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
+
+        assert _woken_printer_ids(power_on) == [2]

+ 47 - 0
frontend/src/__tests__/components/FilamentHoverCard.test.tsx

@@ -152,6 +152,28 @@ describe('FilamentHoverCard', () => {
   // removed that gate so users who don't want to scan via SpoolBuddy NFC
   // can still pick a BL spool from inventory the same way they pick a
   // third-party one.
+  // Paired with the EmptySlotHoverCard assertion below (#2791) — together
+  // they pin the two render paths to the same Assign-then-Configure order.
+  it('lists Assign Spool above Configure (#2791)', async () => {
+    renderWithHover(
+      <FilamentHoverCard
+        data={baseFilamentData}
+        inventory={{ assignedSpool: null, onAssignSpool: vi.fn() }}
+        configureSlot={{ enabled: true, onConfigure: vi.fn() }}
+      >
+        <div>trigger</div>
+      </FilamentHoverCard>
+    );
+    vi.advanceTimersByTime(100);
+    await waitFor(() => expect(screen.getByText(/assign spool/i)).toBeInTheDocument());
+
+    const assign = screen.getByText(/assign spool/i);
+    const configure = screen.getByText(/^configure$/i);
+    expect(assign.compareDocumentPosition(configure)).toBe(
+      Node.DOCUMENT_POSITION_FOLLOWING
+    );
+  });
+
   describe('inventory section vendor visibility (#1133)', () => {
     it('shows the assign-spool button on a Bambu Lab slot when the spool is unassigned', async () => {
       const onAssign = vi.fn();
@@ -485,6 +507,31 @@ describe('EmptySlotHoverCard (#1133)', () => {
     expect(onAssign).toHaveBeenCalledTimes(1);
   });
 
+  // #2791: the empty-slot and filled-slot cards are separate render paths
+  // that had drifted into opposite orders, so the menu reshuffled itself
+  // depending on whether the slot happened to hold filament. Both now put
+  // the spool action above the slot action; assert it on both paths so the
+  // two can't drift apart again.
+  it('lists Assign Spool above Configure, matching the filled-slot card (#2791)', async () => {
+    const result = render(
+      <EmptySlotHoverCard
+        configureSlot={{ enabled: true, onConfigure: vi.fn() }}
+        onAssignSpool={vi.fn()}
+      >
+        <div>trigger</div>
+      </EmptySlotHoverCard>
+    );
+    fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
+    vi.advanceTimersByTime(100);
+    await waitFor(() => expect(screen.getByText(/assign spool/i)).toBeInTheDocument());
+
+    const assign = screen.getByText(/assign spool/i);
+    const configure = screen.getByText(/^configure$/i);
+    expect(assign.compareDocumentPosition(configure)).toBe(
+      Node.DOCUMENT_POSITION_FOLLOWING
+    );
+  });
+
   // Same z-[60]-over-a-z-50-dialog problem as FilamentHoverCard (#2631).
   describe('dismissal when an action opens a dialog (#2631)', () => {
     it('closes the card when Configure is pressed, and still configures', async () => {

+ 78 - 0
frontend/src/__tests__/pages/CloudLoginCaptcha.test.tsx

@@ -0,0 +1,78 @@
+/**
+ * Bambu Cloud sign-in when Bambu is challenging the network with a CAPTCHA (#2790).
+ *
+ * The backend answers `reason: 'captcha'`, meaning no credential will be
+ * accepted until the challenge clears and there is nothing in Bambuddy that can
+ * answer it. A toast is the wrong shape for that: it names a problem the user
+ * cannot act on and then disappears. The reporter saw Bambu's own sentence,
+ * "We need you to confirm you are not a robot", flash by with no challenge
+ * behind it and filed it as a bug.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useTranslation } from 'react-i18next';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { LoginForm } from '../../pages/ProfilesPage';
+import { server } from '../mocks/server';
+
+function Harness() {
+  const { t } = useTranslation();
+  return <LoginForm onSuccess={() => {}} t={t} />;
+}
+
+async function submitCredentials() {
+  const user = userEvent.setup();
+  await user.type(await screen.findByPlaceholderText('your@email.com'), 'user@example.com');
+  await user.type(screen.getByPlaceholderText('••••••••'), 'hunter2');
+  await user.click(screen.getByRole('button', { name: /login/i }));
+  return user;
+}
+
+describe('Bambu Cloud sign-in blocked by a CAPTCHA', () => {
+  it('explains the challenge in place and offers the token route', async () => {
+    server.use(
+      http.post('/api/v1/cloud/login', () =>
+        HttpResponse.json({
+          success: false,
+          needs_verification: false,
+          reason: 'captcha',
+          message: 'We need you to confirm you are not a robot',
+        }),
+      ),
+    );
+
+    render(<Harness />);
+    const user = await submitCredentials();
+
+    const panel = await screen.findByRole('alert');
+    expect(panel).toHaveTextContent(/Bambu Cloud is asking for a CAPTCHA/i);
+    // The two things the reporter had no way to find out.
+    expect(panel).toHaveTextContent(/email and password are not the problem/i);
+    expect(panel).toHaveTextContent(/clears by itself within a few hours/i);
+
+    // Bambu's raw sentence is never what the user is left holding.
+    expect(screen.queryByText('We need you to confirm you are not a robot')).not.toBeInTheDocument();
+
+    // The one action that does work is one click away.
+    await user.click(within(panel).getByRole('button', { name: /use access token instead/i }));
+    expect(await screen.findByPlaceholderText('eyJ...')).toBeInTheDocument();
+    expect(screen.queryByText(/Bambu Cloud is asking for a CAPTCHA/i)).not.toBeInTheDocument();
+  });
+
+  it('leaves an ordinary rejection as a toast', async () => {
+    server.use(
+      http.post('/api/v1/cloud/login', () =>
+        HttpResponse.json({ success: false, needs_verification: false, message: 'Login failed' }),
+      ),
+    );
+
+    render(<Harness />);
+    await submitCredentials();
+
+    await waitFor(() => expect(screen.getByText('Login failed')).toBeInTheDocument());
+    expect(screen.queryByText(/Bambu Cloud is asking for a CAPTCHA/i)).not.toBeInTheDocument();
+  });
+});

+ 120 - 0
frontend/src/__tests__/pages/GCodeViewerPage.test.tsx

@@ -0,0 +1,120 @@
+/**
+ * The G-code viewer's frame, when something refuses to let it be embedded (#2787).
+ *
+ * Sliced files preview through a full-page route whose body is an iframe of
+ * /gcode-viewer/; STL and source 3MF use an in-page three.js modal instead. So a
+ * proxy that injects a framing header breaks exactly one of the two previews,
+ * and all the user sees is the browser's own "refused to connect" page inside
+ * our layout shell — no clue what happened, and no hint that the viewer works
+ * perfectly well in a tab of its own.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { GCodeViewerPage } from '../../pages/GCodeViewerPage';
+import { findFramingRefusal } from '../../utils/framing';
+import { server } from '../mocks/server';
+
+const ORIGIN = 'https://printers.example.com';
+const OURS = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'self';";
+
+function serveViewer(status: number, headers: Record<string, string> = {}) {
+  server.use(http.get('/gcode-viewer/', () => new HttpResponse(null, { status, headers })));
+}
+
+describe('findFramingRefusal', () => {
+  it('accepts the headers Bambuddy itself sends', () => {
+    expect(findFramingRefusal('SAMEORIGIN', OURS, ORIGIN)).toBeNull();
+  });
+
+  it('accepts an origin named explicitly instead of self', () => {
+    const csp = `frame-ancestors ${ORIGIN};`;
+    expect(findFramingRefusal(null, csp, ORIGIN)).toBeNull();
+  });
+
+  it('reports a proxy-added policy that intersects ours down to none', () => {
+    // Two Content-Security-Policy headers arrive as one comma-joined string.
+    // Both apply, so ours permitting us is not enough.
+    const refusal = findFramingRefusal('SAMEORIGIN', `${OURS}, frame-ancestors 'none'`, ORIGIN);
+    expect(refusal).toBe("Content-Security-Policy: frame-ancestors 'none'");
+  });
+
+  it('reports frame-ancestors listing only somebody else', () => {
+    const refusal = findFramingRefusal(null, "frame-ancestors https://ha.example.com;", ORIGIN);
+    expect(refusal).toContain('ha.example.com');
+  });
+
+  it('reports X-Frame-Options DENY when no frame-ancestors is present', () => {
+    expect(findFramingRefusal('DENY', null, ORIGIN)).toBe('X-Frame-Options: DENY');
+  });
+
+  it('reports a second X-Frame-Options appended to ours', () => {
+    expect(findFramingRefusal('SAMEORIGIN, DENY', null, ORIGIN)).toBe(
+      'X-Frame-Options: SAMEORIGIN, DENY',
+    );
+  });
+
+  it('ignores X-Frame-Options when frame-ancestors permits us, as browsers do', () => {
+    // CSP supersedes the legacy header outright — flagging this would blame a
+    // header the browser never consulted.
+    expect(findFramingRefusal('DENY', OURS, ORIGIN)).toBeNull();
+  });
+
+  it('accepts a response carrying no framing headers at all', () => {
+    expect(findFramingRefusal(null, null, ORIGIN)).toBeNull();
+  });
+});
+
+describe('GCodeViewerPage', () => {
+  it('embeds the viewer when nothing refuses the frame', async () => {
+    serveViewer(200, { 'X-Frame-Options': 'SAMEORIGIN', 'Content-Security-Policy': OURS });
+
+    render(<GCodeViewerPage />);
+
+    expect(await screen.findByTitle('GCode Viewer')).toBeInTheDocument();
+    // Give the probe a chance to land and prove it changes nothing.
+    await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
+    expect(screen.getByTitle('GCode Viewer')).toBeInTheDocument();
+  });
+
+  it('explains a refused frame and offers the viewer in its own tab', async () => {
+    serveViewer(200, { 'Content-Security-Policy': "frame-ancestors 'none';" });
+
+    render(<GCodeViewerPage />);
+
+    const panel = await screen.findByRole('alert');
+    expect(panel).toHaveTextContent(/could not be embedded/i);
+    // Name the header so the operator can go and find it in their proxy.
+    expect(panel).toHaveTextContent(/frame-ancestors 'none'/);
+    // A top-level navigation is not subject to frame-ancestors, so this works.
+    const link = within(panel).getByRole('link', { name: /new tab/i });
+    expect(link).toHaveAttribute('href', '/gcode-viewer/');
+    expect(link).toHaveAttribute('target', '_blank');
+    expect(screen.queryByTitle('GCode Viewer')).not.toBeInTheDocument();
+  });
+
+  it('reports missing viewer assets rather than showing raw JSON', async () => {
+    serveViewer(404);
+
+    render(<GCodeViewerPage />);
+
+    const panel = await screen.findByRole('alert');
+    expect(panel).toHaveTextContent(/unavailable/i);
+    expect(panel).toHaveTextContent(/HTTP 404/);
+    expect(screen.queryByTitle('GCode Viewer')).not.toBeInTheDocument();
+  });
+
+  it('keeps the frame when the probe itself fails', async () => {
+    // No evidence either way — the browser's own error page is better than a
+    // guess at a cause we cannot see.
+    server.use(http.get('/gcode-viewer/', () => HttpResponse.error()));
+
+    render(<GCodeViewerPage />);
+
+    expect(await screen.findByTitle('GCode Viewer')).toBeInTheDocument();
+    await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
+    expect(screen.getByTitle('GCode Viewer')).toBeInTheDocument();
+  });
+});

+ 6 - 0
frontend/src/api/client.ts

@@ -1406,6 +1406,12 @@ export interface CloudLoginResponse {
   message: string;
   verification_type?: 'email' | 'totp' | null;
   tfa_key?: string | null;
+  /**
+   * Machine-readable cause of a failure. 'captcha' means Bambu's anti-abuse
+   * layer is challenging this network and no credential will be accepted until
+   * it clears — the UI must explain that in place rather than toast `message`.
+   */
+  reason?: 'captcha' | string | null;
 }
 
 // Orca Cloud types — paste-flow PKCE handshake against auth.orcaslicer.com.

+ 1 - 1
frontend/src/components/Card.tsx

@@ -25,7 +25,7 @@ interface CardSectionProps {
 export function Card({ children, className = '', onClick, onContextMenu, ...rest }: CardProps) {
   return (
     <div
-      className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${className}`}
+      className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${onClick ? 'cursor-pointer' : ''} ${className}`}
       onClick={onClick}
       onContextMenu={onContextMenu}
       {...rest}

+ 1 - 1
frontend/src/components/ContextMenu.tsx

@@ -265,7 +265,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
               }}
               disabled={item.disabled}
               title={item.title}
-              className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
+              className={`group w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
                 item.disabled
                   ? 'text-bambu-gray cursor-not-allowed'
                   : item.danger

+ 22 - 17
frontend/src/components/FilamentHoverCard.tsx

@@ -344,7 +344,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                           dismiss();
                           navigate(`/inventory?spool=${spoolman.linkedSpoolId}`);
                         }}
-                        className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green"
+                        className="w-full flex items-center justify-start gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/40 text-bambu-green"
                         title={t('inventory.openInInventory')}
                       >
                         <Package className="w-3.5 h-3.5" />
@@ -397,7 +397,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                             dismiss();
                             navigate(`/inventory?spool=${inventory.assignedSpool!.id}`);
                           }}
-                          className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green"
+                          className="w-full flex items-center justify-start gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/40 text-bambu-green"
                           title={t('inventory.openInInventory')}
                         >
                           <Package className="w-3.5 h-3.5" />
@@ -411,7 +411,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                             dismiss();
                             inventory.onUnassignSpool?.();
                           }}
-                          className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-red-100 dark:bg-red-500/20 hover:bg-red-200 dark:hover:bg-red-500/30 text-red-700 dark:text-red-400"
+                          className="w-full flex items-center justify-start gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-red-100 dark:bg-red-500/20 hover:bg-red-200 dark:hover:bg-red-500/40 text-red-700 dark:text-red-400"
                         >
                           <Unlink className="w-3.5 h-3.5" />
                           {t('inventory.unassignSpool')}
@@ -426,8 +426,8 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                         inventory.onAssignSpool?.();
                       }}
                       disabled={!!inventory.isAssigned}
-                      className={`w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 text-bambu-blue ${
-                        inventory.isAssigned ? 'opacity-50 cursor-not-allowed' : 'hover:bg-bambu-blue/30'
+                      className={`w-full flex items-center justify-start gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 text-bambu-blue ${
+                        inventory.isAssigned ? 'opacity-50 cursor-not-allowed' : 'hover:bg-bambu-blue/40'
                       }`}
                     >
                       <Package className="w-3.5 h-3.5" />
@@ -446,7 +446,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                       dismiss();
                       configureSlot.onConfigure?.();
                     }}
-                    className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
+                    className="w-full flex items-center justify-start gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/40 text-bambu-blue"
                     title={t('ams.configureSlot')}
                   >
                     <Settings2 className="w-3.5 h-3.5" />
@@ -506,7 +506,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                     spoolman?.onUnlinkSpool?.();
                     setShowUnlinkConfirm(false);
                   }}
-                  className="flex-1 px-3 py-2 text-sm font-medium rounded transition-colors bg-red-100 dark:bg-red-500/20 hover:bg-red-200 dark:hover:bg-red-500/30 text-red-700 dark:text-red-400"
+                  className="flex-1 px-3 py-2 text-sm font-medium rounded transition-colors bg-red-100 dark:bg-red-500/20 hover:bg-red-200 dark:hover:bg-red-500/40 text-red-700 dark:text-red-400"
                 >
                   {t('inventory.unassignSpool')}
                 </button>
@@ -622,6 +622,20 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, on
             {/* Configure slot button */}
             {(configureSlot?.enabled || onAssignSpool || actions) && (
               <div className="px-2 pb-2 space-y-1">
+                {/* Assign before Configure, matching the filled-slot card
+                    above (#2791).  The two cards are separate render paths
+                    and had drifted into opposite orders, so the menu
+                    reshuffled itself depending on whether the slot happened
+                    to hold filament. */}
+                {onAssignSpool && (
+                  <button
+                    onClick={(e) => { e.stopPropagation(); dismiss(); onAssignSpool(); }}
+                    className="w-full flex items-center justify-start gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/40 text-bambu-blue"
+                  >
+                    <Package className="w-3.5 h-3.5" />
+                    {t('inventory.assignSpool')}
+                  </button>
+                )}
                 {configureSlot?.enabled && (
                   <button
                     onClick={(e) => {
@@ -629,22 +643,13 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, on
                       dismiss();
                       configureSlot.onConfigure?.();
                     }}
-                    className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
+                    className="w-full flex items-center justify-start gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/40 text-bambu-blue"
                     title={t('ams.configureSlot')}
                   >
                     <Settings2 className="w-3.5 h-3.5" />
                     {t('ams.configure')}
                   </button>
                 )}
-                {onAssignSpool && (
-                  <button
-                    onClick={(e) => { e.stopPropagation(); dismiss(); onAssignSpool(); }}
-                    className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
-                  >
-                    <Package className="w-3.5 h-3.5" />
-                    {t('inventory.assignSpool')}
-                  </button>
-                )}
                 {actions && (
                   <div className="pt-1 mt-1 border-t border-bambu-dark-tertiary space-y-1">
                     {actions}

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

@@ -3597,6 +3597,8 @@ export default {
       verifyButton: 'Bestätigen',
       setTokenButton: 'Token setzen',
       useToken: 'Stattdessen Zugriffstoken verwenden',
+      captchaTitle: 'Bambu Cloud verlangt ein CAPTCHA',
+      captchaBody: 'Bambu fordert für dein Netzwerk eine CAPTCHA-Prüfung, bevor eine Anmeldung akzeptiert wird, und diese Prüfung lässt sich aus Bambuddy heraus nicht beantworten. E-Mail und Passwort sind nicht das Problem. Die Sperre hängt an deiner öffentlichen IP-Adresse und löst sich normalerweise innerhalb weniger Stunden von selbst — wiederholte Versuche verlängern sie. Um dich jetzt anzumelden, verwende stattdessen ein Zugriffstoken aus einer Browser-Sitzung.',
       useEmail: 'Stattdessen mit E-Mail anmelden',
       toast: {
         loggedIn: 'Erfolgreich angemeldet',
@@ -6723,6 +6725,7 @@ export default {
         title: 'Dateiübertragungsport (FTPS 990)',
         pass: 'Erreichbar — das Senden von Druckdateien funktioniert.',
         warn: 'Port 990 ist nicht erreichbar. Die Überwachung funktioniert möglicherweise weiterhin, aber das Senden von Drucken an den Drucker schlägt fehl. Stellen Sie sicher, dass Port 990 nicht blockiert ist.',
+        warn_no_tls: 'Port 990 ist offen, aber der Dateidienst des Druckers schließt den TLS-Handshake nicht ab. Druckdateien, Vorschaubilder und Zeitraffer können nicht abgerufen werden, daher bleiben Archive leer. Starten Sie den Drucker neu — den Port freizugeben hilft hier nicht.',
       },
       external_storage: {
         title: 'Gesendete Dateien auf externem Speicher speichern (Installationsschritt 4)',
@@ -6797,8 +6800,13 @@ export default {
       },
       'ftp-ssl-error': {
         name: 'Sicherer Dateiübertragungs-Handshake fehlgeschlagen',
-        cause: 'Der TLS-Handshake mit dem Dateiübertragungs-Server des Druckers ist fehlgeschlagen. Häufig liegt das an einer Firewall oder veralteter Drucker-Firmware.',
-        fix: 'Aktualisiere die Drucker-Firmware und prüfe, dass keine Firewall oder Proxy die Verbindung auf Port 990 abfängt.',
+        cause: 'Der Dateidienst des Druckers antwortet auf Port 990 ohne TLS. Sein Dateiserver hat sich aufgehängt — ein Fehler im Drucker, kein Firewall- oder Firmware-Problem.',
+        fix: 'Starte den Drucker neu. Bis dahin lassen sich Druckdateien, Vorschaubilder und Timelapses nicht abrufen; das Drucken selbst ist nicht betroffen.',
+      },
+      'bambu-cloud-captcha': {
+        name: 'Bambu Cloud verlangt ein CAPTCHA',
+        cause: 'Der Missbrauchsschutz von Bambu prüft dieses Netzwerk, daher kann keine Bambu-Cloud-Anmeldung abgeschlossen werden. Das hängt an der öffentlichen IP-Adresse, nicht an deinem Konto oder dieser Installation.',
+        fix: 'Warte ab — normalerweise löst es sich innerhalb weniger Stunden, wiederholte Anmeldeversuche verlängern es. Zwischenzeitlich kannst du dich mit einem Zugriffstoken aus einer Browser-Sitzung anmelden.',
       },
       'mqtt-connection-flapping': {
         name: 'Druckerverbindung bricht ständig ab',
@@ -7006,6 +7014,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: '3D-Vorschau konnte nicht eingebettet werden',
+    blockedBody: 'Bambuddy erlaubt dieser Seite, den G-Code-Viewer eingebettet anzuzeigen, aber etwas zwischen Ihrem Browser und Bambuddy verweigert das — meist ein Reverse-Proxy oder eine Sicherheitserweiterung, die einen eigenen Frame-Header sendet. Das Öffnen des Viewers in einem eigenen Tab ist davon nicht betroffen.',
+    unavailableTitle: '3D-Vorschau nicht verfügbar',
+    unavailableBody: 'Bambuddy konnte die Dateien des G-Code-Viewers nicht ausliefern. Normalerweise fehlt dann das Verzeichnis gcode_viewer in der Installation; das Startprotokoll weist ebenfalls darauf hin.',
+    problemDetail: 'Meldung des Servers: {{detail}}',
+    openInNewTab: 'Viewer in neuem Tab öffnen',
     back: 'Zurück',
     backToArchives: 'Zurück zum Druckarchiv',
     backToFiles: 'Zurück zum Dateimanager',

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

@@ -3626,6 +3626,8 @@ export default {
       verifyButton: 'Verify',
       setTokenButton: 'Set Token',
       useToken: 'Use access token instead',
+      captchaTitle: 'Bambu Cloud is asking for a CAPTCHA',
+      captchaBody: 'Bambu is challenging your network before it will accept a sign-in, and the challenge cannot be answered from Bambuddy. Your email and password are not the problem. The block is tied to your public IP address and normally clears by itself within a few hours — retrying repeatedly makes it last longer. To sign in now, use an access token from a browser session instead.',
       useEmail: 'Login with email instead',
       toast: {
         loggedIn: 'Logged in successfully',
@@ -6772,6 +6774,7 @@ export default {
         title: 'File transfer port (FTPS 990)',
         pass: 'Reachable — sending print files will work.',
         warn: 'Port 990 is unreachable. Monitoring may still work, but sending prints to the printer will fail. Make sure port 990 is not blocked.',
+        warn_no_tls: 'Port 990 is open but the printer\'s file service is not completing a TLS handshake. Print files, covers and timelapses cannot be fetched, so archives stay empty. Restart the printer — unblocking the port will not help.',
       },
       external_storage: {
         title: 'Store sent files on external storage (install step 4)',
@@ -6846,8 +6849,13 @@ export default {
       },
       'ftp-ssl-error': {
         name: 'Secure file-transfer handshake failed',
-        cause: 'The TLS handshake with the printer\'s file-transfer server failed. This is often a firewall or outdated printer firmware.',
-        fix: 'Update the printer firmware and check that no firewall or proxy intercepts the connection on port 990.',
+        cause: 'The printer\'s file service answered port 990 without TLS. Its file server has wedged — a printer-side fault, not a firewall or firmware problem.',
+        fix: 'Restart the printer. Until then print files, covers and timelapses cannot be fetched; printing itself is unaffected.',
+      },
+      'bambu-cloud-captcha': {
+        name: 'Bambu Cloud is asking for a CAPTCHA',
+        cause: 'Bambu\'s anti-abuse layer is challenging this network, so no Bambu Cloud sign-in can complete. It is tied to the public IP address, not to your account or this installation.',
+        fix: 'Wait — it normally clears within a few hours, and repeated sign-in attempts prolong it. To connect meanwhile, sign in with an access token taken from a browser session.',
       },
       'mqtt-connection-flapping': {
         name: 'Printer connection keeps dropping',
@@ -7055,6 +7063,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: 'The 3D preview could not be embedded',
+    blockedBody: 'Bambuddy allows this page to show the G-code viewer inline, but something between your browser and Bambuddy is refusing it — usually a reverse proxy or a security add-on sending its own framing header. Opening the viewer in its own tab is not affected.',
+    unavailableTitle: 'The 3D preview is unavailable',
+    unavailableBody: 'Bambuddy could not serve the G-code viewer\'s files. This normally means the gcode_viewer directory is missing from the installation; the startup log says so too.',
+    problemDetail: 'Reported by the server: {{detail}}',
+    openInNewTab: 'Open the viewer in a new tab',
     back: 'Back',
     backToArchives: 'Back to Print Archives',
     backToFiles: 'Back to File Manager',

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

@@ -3599,6 +3599,8 @@ export default {
       verifyButton: 'Verificar',
       setTokenButton: 'Establecer token',
       useToken: 'Usar token de acceso en su lugar',
+      captchaTitle: 'Bambu Cloud solicita un CAPTCHA',
+      captchaBody: 'Bambu está exigiendo un CAPTCHA a tu red antes de aceptar un inicio de sesión, y ese desafío no se puede responder desde Bambuddy. Tu correo y tu contraseña no son el problema. El bloqueo está ligado a tu dirección IP pública y suele desaparecer solo en unas horas; reintentar repetidamente lo prolonga. Para entrar ahora, usa un token de acceso obtenido en una sesión del navegador.',
       useEmail: 'Iniciar sesión con correo en su lugar',
       toast: {
         loggedIn: 'Sesión iniciada correctamente',
@@ -6731,6 +6733,7 @@ export default {
         title: 'Puerto de transferencia de archivos (FTPS 990)',
         pass: 'Accesible — el envío de archivos de impresión funcionará.',
         warn: 'El puerto 990 no es accesible. La supervisión puede seguir funcionando, pero el envío de impresiones a la impresora fallará. Asegúrese de que el puerto 990 no esté bloqueado.',
+        warn_no_tls: 'El puerto 990 está abierto, pero el servicio de archivos de la impresora no completa el protocolo de enlace TLS. No se pueden obtener los archivos de impresión, las portadas ni los timelapses, por lo que los archivos quedan vacíos. Reinicie la impresora — desbloquear el puerto no servirá.',
       },
       external_storage: {
         title: 'Almacenar archivos enviados en almacenamiento externo (paso 4 de instalación)',
@@ -6805,8 +6808,13 @@ export default {
       },
       'ftp-ssl-error': {
         name: 'Falló el protocolo de enlace seguro de transferencia de archivos',
-        cause: 'El protocolo de enlace TLS con el servidor de transferencia de archivos de la impresora falló. Suele deberse a un cortafuegos o a un firmware de impresora desactualizado.',
-        fix: 'Actualiza el firmware de la impresora y comprueba que ningún cortafuegos o proxy intercepte la conexión en el puerto 990.',
+        cause: 'El servicio de archivos de la impresora respondió en el puerto 990 sin TLS. Su servidor de archivos se ha bloqueado: es un fallo de la impresora, no del cortafuegos ni del firmware.',
+        fix: 'Reinicia la impresora. Hasta entonces no se pueden descargar archivos de impresión, portadas ni timelapses; la impresión en sí no se ve afectada.',
+      },
+      'bambu-cloud-captcha': {
+        name: 'Bambu Cloud solicita un CAPTCHA',
+        cause: 'La protección antiabuso de Bambu está verificando esta red, por lo que ningún inicio de sesión en Bambu Cloud puede completarse. Depende de la dirección IP pública, no de tu cuenta ni de esta instalación.',
+        fix: 'Espera: suele resolverse en unas horas y los intentos repetidos lo prolongan. Mientras tanto, conéctate con un token de acceso obtenido en una sesión del navegador.',
       },
       'mqtt-connection-flapping': {
         name: 'La conexión con la impresora se cae continuamente',
@@ -7014,6 +7022,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: 'No se pudo incrustar la vista previa 3D',
+    blockedBody: 'Bambuddy permite que esta página muestre el visor de G-code incrustado, pero algo entre su navegador y Bambuddy lo está rechazando — normalmente un proxy inverso o un complemento de seguridad que envía su propia cabecera de marco. Abrir el visor en su propia pestaña no se ve afectado.',
+    unavailableTitle: 'La vista previa 3D no está disponible',
+    unavailableBody: 'Bambuddy no pudo servir los archivos del visor de G-code. Esto suele significar que falta el directorio gcode_viewer en la instalación; el registro de inicio también lo indica.',
+    problemDetail: 'Informado por el servidor: {{detail}}',
+    openInNewTab: 'Abrir el visor en una pestaña nueva',
     back: 'Atrás',
     backToArchives: 'Volver a los archivos de impresión',
     backToFiles: 'Volver al gestor de archivos',

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

@@ -3586,6 +3586,8 @@ export default {
       verifyButton: 'Vérifier',
       setTokenButton: 'Définir Jeton',
       useToken: 'Utiliser jeton d\'accès',
+      captchaTitle: 'Bambu Cloud demande un CAPTCHA',
+      captchaBody: 'Bambu impose un CAPTCHA à votre réseau avant d\'accepter une connexion, et ce défi ne peut pas être résolu depuis Bambuddy. Votre e-mail et votre mot de passe ne sont pas en cause. Le blocage est lié à votre adresse IP publique et disparaît généralement de lui-même en quelques heures ; réessayer sans cesse le prolonge. Pour vous connecter maintenant, utilisez plutôt un jeton d\'accès issu d\'une session de navigateur.',
       useEmail: 'Connexion par email',
       toast: {
         loggedIn: 'Connecté avec succès',
@@ -6713,6 +6715,7 @@ export default {
         title: 'Port de transfert de fichiers (FTPS 990)',
         pass: 'Accessible — l\'envoi de fichiers d\'impression fonctionnera.',
         warn: 'Le port 990 est inaccessible. La surveillance peut toujours fonctionner, mais l\'envoi d\'impressions vers l\'imprimante échouera. Assurez-vous que le port 990 n\'est pas bloqué.',
+        warn_no_tls: 'Le port 990 est ouvert, mais le service de fichiers de l\'imprimante ne termine pas la négociation TLS. Les fichiers d\'impression, les aperçus et les timelapses ne peuvent pas être récupérés, les archives restent donc vides. Redémarrez l\'imprimante — débloquer le port n\'y changera rien.',
       },
       external_storage: {
         title: 'Stocker les fichiers envoyés sur stockage externe (étape 4 de l\'installation)',
@@ -6787,8 +6790,13 @@ export default {
       },
       'ftp-ssl-error': {
         name: 'Échec de la négociation sécurisée du transfert de fichiers',
-        cause: 'La négociation TLS avec le serveur de transfert de fichiers de l\'imprimante a échoué. C\'est souvent dû à un pare-feu ou à un micrologiciel d\'imprimante obsolète.',
-        fix: 'Mettez à jour le micrologiciel de l\'imprimante et vérifiez qu\'aucun pare-feu ou proxy n\'intercepte la connexion sur le port 990.',
+        cause: 'Le service de fichiers de l\'imprimante a répondu sur le port 990 sans TLS. Son serveur de fichiers est bloqué : c\'est une panne côté imprimante, pas un problème de pare-feu ou de micrologiciel.',
+        fix: 'Redémarrez l\'imprimante. D\'ici là, les fichiers d\'impression, les vignettes et les timelapses ne peuvent pas être récupérés ; l\'impression elle-même n\'est pas affectée.',
+      },
+      'bambu-cloud-captcha': {
+        name: 'Bambu Cloud demande un CAPTCHA',
+        cause: 'La protection anti-abus de Bambu contrôle ce réseau, aucune connexion à Bambu Cloud ne peut donc aboutir. Cela dépend de l\'adresse IP publique, pas de votre compte ni de cette installation.',
+        fix: 'Patientez : cela disparaît généralement en quelques heures, et les tentatives répétées le prolongent. En attendant, connectez-vous avec un jeton d\'accès issu d\'une session de navigateur.',
       },
       'mqtt-connection-flapping': {
         name: 'La connexion à l\'imprimante se coupe sans cesse',
@@ -6995,6 +7003,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: 'L\'aperçu 3D n\'a pas pu être intégré',
+    blockedBody: 'Bambuddy autorise cette page à afficher la visionneuse G-code en ligne, mais quelque chose entre votre navigateur et Bambuddy le refuse — généralement un reverse proxy ou une extension de sécurité qui envoie son propre en-tête de cadre. L\'ouverture de la visionneuse dans un onglet dédié n\'est pas concernée.',
+    unavailableTitle: 'L\'aperçu 3D est indisponible',
+    unavailableBody: 'Bambuddy n\'a pas pu servir les fichiers de la visionneuse G-code. Cela signifie généralement que le répertoire gcode_viewer est absent de l\'installation ; le journal de démarrage l\'indique également.',
+    problemDetail: 'Signalé par le serveur : {{detail}}',
+    openInNewTab: 'Ouvrir la visionneuse dans un nouvel onglet',
     back: 'Retour',
     backToArchives: 'Retour aux archives d\'impression',
     backToFiles: 'Retour au gestionnaire de fichiers',

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

@@ -3585,6 +3585,8 @@ export default {
       verifyButton: 'Verifica',
       setTokenButton: 'Imposta token',
       useToken: 'Usa access token invece',
+      captchaTitle: 'Bambu Cloud richiede un CAPTCHA',
+      captchaBody: 'Bambu sta richiedendo un CAPTCHA alla tua rete prima di accettare un accesso, e la verifica non può essere completata da Bambuddy. Email e password non sono il problema. Il blocco è legato al tuo indirizzo IP pubblico e di solito si risolve da solo entro qualche ora; riprovare di continuo lo prolunga. Per accedere subito, usa invece un token di accesso preso da una sessione del browser.',
       useEmail: 'Accedi con email invece',
       toast: {
         loggedIn: 'Accesso riuscito',
@@ -6712,6 +6714,7 @@ export default {
         title: 'Porta trasferimento file (FTPS 990)',
         pass: 'Raggiungibile — l\'invio dei file di stampa funzionerà.',
         warn: 'La porta 990 non è raggiungibile. Il monitoraggio potrebbe ancora funzionare, ma l\'invio delle stampe alla stampante fallirà. Assicurati che la porta 990 non sia bloccata.',
+        warn_no_tls: 'La porta 990 è aperta, ma il servizio file della stampante non completa l\'handshake TLS. I file di stampa, le anteprime e i timelapse non possono essere scaricati, quindi gli archivi restano vuoti. Riavvia la stampante — sbloccare la porta non serve.',
       },
       external_storage: {
         title: 'Memorizza file inviati su archiviazione esterna (passo 4 dell\'installazione)',
@@ -6786,8 +6789,13 @@ export default {
       },
       'ftp-ssl-error': {
         name: 'Handshake sicuro del trasferimento file non riuscito',
-        cause: 'L\'handshake TLS con il server di trasferimento file della stampante non è riuscito. Spesso è dovuto a un firewall o a un firmware della stampante obsoleto.',
-        fix: 'Aggiorna il firmware della stampante e verifica che nessun firewall o proxy intercetti la connessione sulla porta 990.',
+        cause: 'Il servizio file della stampante ha risposto sulla porta 990 senza TLS. Il suo server file si è bloccato: è un guasto della stampante, non un problema di firewall o firmware.',
+        fix: 'Riavvia la stampante. Fino ad allora non è possibile scaricare file di stampa, copertine e timelapse; la stampa in sé non è interessata.',
+      },
+      'bambu-cloud-captcha': {
+        name: 'Bambu Cloud richiede un CAPTCHA',
+        cause: 'Il sistema antiabuso di Bambu sta verificando questa rete, quindi nessun accesso a Bambu Cloud può andare a buon fine. Dipende dall\'indirizzo IP pubblico, non dal tuo account né da questa installazione.',
+        fix: 'Attendi: di solito si risolve entro qualche ora e i tentativi ripetuti lo prolungano. Nel frattempo accedi con un token di accesso preso da una sessione del browser.',
       },
       'mqtt-connection-flapping': {
         name: 'La connessione alla stampante cade di continuo',
@@ -6994,6 +7002,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: 'Impossibile incorporare l\'anteprima 3D',
+    blockedBody: 'Bambuddy consente a questa pagina di mostrare il visualizzatore G-code incorporato, ma qualcosa tra il browser e Bambuddy lo rifiuta — di solito un reverse proxy o un\'estensione di sicurezza che invia una propria intestazione di frame. L\'apertura del visualizzatore in una scheda dedicata non è interessata.',
+    unavailableTitle: 'Anteprima 3D non disponibile',
+    unavailableBody: 'Bambuddy non è riuscito a servire i file del visualizzatore G-code. Di solito significa che la cartella gcode_viewer manca nell\'installazione; anche il log di avvio lo segnala.',
+    problemDetail: 'Segnalato dal server: {{detail}}',
+    openInNewTab: 'Apri il visualizzatore in una nuova scheda',
     back: 'Indietro',
     backToArchives: 'Torna agli archivi di stampa',
     backToFiles: 'Torna al gestore file',

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

@@ -3597,6 +3597,8 @@ export default {
       verifyButton: '認証',
       setTokenButton: 'トークンを設定',
       useToken: 'アクセストークンを使用',
+      captchaTitle: 'Bambu Cloud が CAPTCHA を要求しています',
+      captchaBody: 'Bambu がサインインを受け付ける前にネットワークへ CAPTCHA を要求しており、この認証は Bambuddy からは応答できません。メールアドレスやパスワードの問題ではありません。ブロックはグローバル IP アドレスに紐づいており、通常は数時間で自動的に解除されます。繰り返し再試行すると解除が遅くなります。今すぐサインインするには、ブラウザーのセッションから取得したアクセストークンを使用してください。',
       useEmail: 'メールでログイン',
       toast: {
         loggedIn: 'ログインしました',
@@ -6724,6 +6726,7 @@ export default {
         title: 'ファイル転送ポート (FTPS 990)',
         pass: '到達可能 — 印刷ファイルの送信は機能します。',
         warn: 'ポート990に到達できません。監視は引き続き機能する場合がありますが、プリンターへの印刷送信は失敗します。ポート990がブロックされていないことを確認してください。',
+        warn_no_tls: 'ポート990は開いていますが、プリンターのファイルサービスがTLSハンドシェイクを完了しません。印刷ファイル、サムネイル、タイムラプスを取得できないため、アーカイブは空のままになります。プリンターを再起動してください — ポートのブロックを解除しても解決しません。',
       },
       external_storage: {
         title: '送信ファイルを外部ストレージに保存 (インストール手順4)',
@@ -6798,8 +6801,13 @@ export default {
       },
       'ftp-ssl-error': {
         name: 'セキュアなファイル転送のハンドシェイクに失敗しました',
-        cause: 'プリンターのファイル転送サーバーとの TLS ハンドシェイクに失敗しました。多くはファイアウォールか、プリンターのファームウェアが古いことが原因です。',
-        fix: 'プリンターのファームウェアを更新し、ファイアウォールやプロキシがポート 990 の接続を妨げていないか確認してください。',
+        cause: 'プリンターのファイルサービスがポート 990 で TLS を使わずに応答しました。プリンター側のファイルサーバーがハングした状態で、ファイアウォールやファームウェアの問題ではありません。',
+        fix: 'プリンターを再起動してください。それまで印刷ファイル・サムネイル・タイムラプスは取得できませんが、印刷自体には影響ありません。',
+      },
+      'bambu-cloud-captcha': {
+        name: 'Bambu Cloud が CAPTCHA を要求しています',
+        cause: 'Bambu の不正利用対策がこのネットワークを検証しているため、Bambu Cloud へのサインインを完了できません。アカウントやこのインストール環境ではなく、グローバル IP アドレスに紐づいた制限です。',
+        fix: '待つのが基本です。通常は数時間で解除され、サインインを繰り返すと長引きます。それまでは、ブラウザーのセッションから取得したアクセストークンでサインインしてください。',
       },
       'mqtt-connection-flapping': {
         name: 'プリンター接続が繰り返し切断されます',
@@ -7006,6 +7014,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: '3Dプレビューを埋め込めませんでした',
+    blockedBody: 'BambuddyはこのページにG-codeビューアーを埋め込んで表示することを許可していますが、ブラウザーとBambuddyの間にある何かがそれを拒否しています。多くの場合、独自のフレームヘッダーを送信するリバースプロキシやセキュリティ拡張が原因です。ビューアーを別のタブで開く場合は影響ありません。',
+    unavailableTitle: '3Dプレビューを利用できません',
+    unavailableBody: 'BambuddyがG-codeビューアーのファイルを配信できませんでした。通常はインストールに gcode_viewer ディレクトリが存在しないことを意味します。起動ログにも記録されています。',
+    problemDetail: 'サーバーからの報告: {{detail}}',
+    openInNewTab: 'ビューアーを新しいタブで開く',
     back: '戻る',
     backToArchives: '印刷アーカイブに戻る',
     backToFiles: 'ファイル管理に戻る',

+ 17 - 3
frontend/src/i18n/locales/ko.ts

@@ -3419,6 +3419,8 @@ export default {
       verifyButton: '인증',
       setTokenButton: '토큰 설정',
       useToken: '액세스 토큰 대신 사용',
+      captchaTitle: 'Bambu Cloud가 CAPTCHA를 요구합니다',
+      captchaBody: 'Bambu가 로그인을 처리하기 전에 네트워크에 CAPTCHA 확인을 요구하고 있으며, 이 확인은 Bambuddy에서 응답할 수 없습니다. 이메일과 비밀번호의 문제가 아닙니다. 차단은 공용 IP 주소에 연결되어 있으며 보통 몇 시간 안에 저절로 풀립니다. 반복해서 재시도하면 오히려 더 오래 지속됩니다. 지금 로그인하려면 브라우저 세션에서 가져온 액세스 토큰을 사용하세요.',
       useEmail: '이메일로 로그인',
       toast: {
         loggedIn: '성공적으로 로그인되었습니다',
@@ -6468,6 +6470,12 @@ export default {
     }
   },
   gcodeViewer: {
+    blockedTitle: '3D 미리보기를 삽입할 수 없습니다',
+    blockedBody: 'Bambuddy는 이 페이지에 G-code 뷰어를 삽입해 표시하도록 허용하지만, 브라우저와 Bambuddy 사이의 무언가가 이를 거부하고 있습니다. 대개 자체 프레임 헤더를 보내는 리버스 프록시나 보안 추가 기능이 원인입니다. 뷰어를 별도 탭에서 여는 것은 영향을 받지 않습니다.',
+    unavailableTitle: '3D 미리보기를 사용할 수 없습니다',
+    unavailableBody: 'Bambuddy가 G-code 뷰어 파일을 제공하지 못했습니다. 보통 설치본에 gcode_viewer 디렉터리가 없다는 뜻이며, 시작 로그에도 기록됩니다.',
+    problemDetail: '서버 보고: {{detail}}',
+    openInNewTab: '새 탭에서 뷰어 열기',
     back: '뒤로',
     backToArchives: '인쇄 아카이브로 돌아가기',
     backToFiles: '파일 관리자로 돌아가기'
@@ -6794,7 +6802,8 @@ export default {
       port_ftps: {
         title: '파일 전송 포트 (FTPS 990)',
         pass: '연결 가능 — 인쇄 파일 전송이 작동합니다.',
-        warn: '포트 990에 연결할 수 없습니다. 모니터링은 작동할 수 있지만 프린터로 파일 전송에 실패합니다. 포트 990이 차단되지 않았는지 확인하세요.'
+        warn: '포트 990에 연결할 수 없습니다. 모니터링은 작동할 수 있지만 프린터로 파일 전송에 실패합니다. 포트 990이 차단되지 않았는지 확인하세요.',
+        warn_no_tls: '포트 990은 열려 있지만 프린터의 파일 서비스가 TLS 핸드셰이크를 완료하지 못합니다. 인쇄 파일, 미리보기, 타임랩스를 가져올 수 없어 아카이브가 비어 있게 됩니다. 프린터를 재시작하세요 — 포트 차단을 해제해도 해결되지 않습니다.'
       },
       external_storage: {
         title: '전송된 파일을 외부 저장소에 저장 (설치 단계 4)',
@@ -6868,8 +6877,13 @@ export default {
       },
       "ftp-ssl-error": {
         name: '보안 파일 전송 핸드셰이크 실패',
-        cause: '프린터의 파일 전송 서버와의 TLS 핸드셰이크가 실패했습니다. 주로 방화벽이나 오래된 프린터 펌웨어가 원인입니다.',
-        fix: '프린터 펌웨어를 업데이트하고 방화벽이나 프록시가 포트 990에서 연결을 가로채지 않는지 확인하세요.'
+        cause: '프린터의 파일 서비스가 990 포트에서 TLS 없이 응답했습니다. 프린터 쪽 파일 서버가 멈춘 상태이며 방화벽이나 펌웨어 문제가 아닙니다.',
+        fix: '프린터를 재시작하세요. 그때까지 인쇄 파일, 커버 이미지, 타임랩스를 가져올 수 없지만 인쇄 자체에는 영향이 없습니다.'
+      },
+      "bambu-cloud-captcha": {
+        name: 'Bambu Cloud가 CAPTCHA를 요구합니다',
+        cause: 'Bambu의 남용 방지 계층이 이 네트워크를 확인하고 있어 Bambu Cloud 로그인을 완료할 수 없습니다. 계정이나 이 설치 환경이 아니라 공용 IP 주소에 연결된 문제입니다.',
+        fix: '기다리세요. 보통 몇 시간 안에 해제되며 로그인을 반복하면 더 길어집니다. 그동안에는 브라우저 세션에서 가져온 액세스 토큰으로 로그인하세요.'
       },
       "mqtt-connection-flapping": {
         name: '프린터 연결이 계속 끊김',

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

@@ -3585,6 +3585,8 @@ export default {
       verifyButton: 'Verificar',
       setTokenButton: 'Definir Token',
       useToken: 'Usar token de acesso em vez disso',
+      captchaTitle: 'O Bambu Cloud está pedindo um CAPTCHA',
+      captchaBody: 'O Bambu está exigindo um CAPTCHA da sua rede antes de aceitar um login, e esse desafio não pode ser respondido pelo Bambuddy. Seu e-mail e sua senha não são o problema. O bloqueio está ligado ao seu endereço IP público e costuma passar sozinho em algumas horas; tentar de novo repetidamente prolonga o bloqueio. Para entrar agora, use um token de acesso obtido em uma sessão do navegador.',
       useEmail: 'Entrar com email em vez disso',
       toast: {
         loggedIn: 'Conectado com sucesso',
@@ -6712,6 +6714,7 @@ export default {
         title: 'Porta de transferência de arquivos (FTPS 990)',
         pass: 'Acessível — o envio de arquivos de impressão funcionará.',
         warn: 'A porta 990 está inacessível. O monitoramento ainda pode funcionar, mas o envio de impressões para a impressora falhará. Verifique se a porta 990 não está bloqueada.',
+        warn_no_tls: 'A porta 990 está aberta, mas o serviço de arquivos da impressora não conclui o handshake TLS. Os arquivos de impressão, as capas e os timelapses não podem ser obtidos, então os arquivos ficam vazios. Reinicie a impressora — desbloquear a porta não resolve.',
       },
       external_storage: {
         title: 'Armazenar arquivos enviados no armazenamento externo (passo 4 da instalação)',
@@ -6786,8 +6789,13 @@ export default {
       },
       'ftp-ssl-error': {
         name: 'Falha no handshake seguro de transferência de arquivos',
-        cause: 'O handshake TLS com o servidor de transferência de arquivos da impressora falhou. Geralmente é causado por um firewall ou firmware desatualizado da impressora.',
-        fix: 'Atualize o firmware da impressora e verifique se nenhum firewall ou proxy intercepta a conexão na porta 990.',
+        cause: 'O serviço de arquivos da impressora respondeu na porta 990 sem TLS. O servidor de arquivos dela travou: é uma falha da impressora, não do firewall nem do firmware.',
+        fix: 'Reinicie a impressora. Até lá, arquivos de impressão, capas e timelapses não podem ser baixados; a impressão em si não é afetada.',
+      },
+      'bambu-cloud-captcha': {
+        name: 'O Bambu Cloud está pedindo um CAPTCHA',
+        cause: 'A proteção antiabuso do Bambu está verificando esta rede, então nenhum login no Bambu Cloud consegue ser concluído. Isso depende do endereço IP público, não da sua conta nem desta instalação.',
+        fix: 'Aguarde: costuma passar em algumas horas e novas tentativas prolongam o bloqueio. Enquanto isso, conecte-se com um token de acesso obtido em uma sessão do navegador.',
       },
       'mqtt-connection-flapping': {
         name: 'A conexão com a impressora cai repetidamente',
@@ -6994,6 +7002,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: 'Não foi possível incorporar a pré-visualização 3D',
+    blockedBody: 'O Bambuddy permite que esta página mostre o visualizador de G-code incorporado, mas algo entre o seu navegador e o Bambuddy está recusando — normalmente um proxy reverso ou um complemento de segurança que envia o próprio cabeçalho de quadro. Abrir o visualizador em uma aba própria não é afetado.',
+    unavailableTitle: 'A pré-visualização 3D está indisponível',
+    unavailableBody: 'O Bambuddy não conseguiu servir os arquivos do visualizador de G-code. Isso normalmente significa que o diretório gcode_viewer está ausente na instalação; o log de inicialização também informa isso.',
+    problemDetail: 'Informado pelo servidor: {{detail}}',
+    openInNewTab: 'Abrir o visualizador em uma nova aba',
     back: 'Voltar',
     backToArchives: 'Voltar para os arquivos de impressão',
     backToFiles: 'Voltar para o gerenciador de arquivos',

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

@@ -3411,6 +3411,8 @@ export default {
       verifyButton: "Подтвердить",
       setTokenButton: "Сохранить токен",
       useToken: "Использовать токен доступа",
+      captchaTitle: "Bambu Cloud требует пройти CAPTCHA",
+      captchaBody: "Bambu требует от вашей сети пройти CAPTCHA, прежде чем принять вход, и ответить на эту проверку из Bambuddy невозможно. Дело не в почте и не в пароле. Блокировка привязана к вашему публичному IP-адресу и обычно снимается сама в течение нескольких часов, а повторные попытки только продлевают её. Чтобы войти сейчас, используйте токен доступа из сессии браузера.",
       useEmail: "Войти по email",
       toast: {
         loggedIn: "Вход выполнен",
@@ -6352,6 +6354,7 @@ export default {
         title: "Порт передачи файлов (FTPS 990)",
         pass: "Доступен — отправка файлов печати будет работать.",
         warn: "Порт 990 недоступен. Мониторинг может работать, но отправка заданий на принтер завершится ошибкой. Убедитесь, что порт 990 не заблокирован.",
+        warn_no_tls: "Порт 990 открыт, но файловая служба принтера не завершает рукопожатие TLS. Файлы печати, обложки и таймлапсы получить невозможно, поэтому архивы остаются пустыми. Перезагрузите принтер — разблокировка порта не поможет.",
       },
       external_storage: {
         title: "Сохранять отправленные файлы во внешнем хранилище (шаг 4 установки)",
@@ -6425,8 +6428,13 @@ export default {
       },
       "ftp-ssl-error": {
         name: "Ошибка защищённого соединения с файловой службой",
-        cause: "Не удалось выполнить TLS-рукопожатие с сервером передачи файлов принтера. Частые причины — межсетевой экран или устаревшая прошивка принтера.",
-        fix: "Обновите прошивку принтера и убедитесь, что межсетевой экран или прокси не перехватывает соединение на порту 990.",
+        cause: "Файловая служба принтера ответила на порту 990 без TLS. Её файловый сервер завис — это неисправность самого принтера, а не межсетевого экрана или прошивки.",
+        fix: "Перезапустите принтер. До этого файлы печати, обложки и таймлапсы получить нельзя, но на саму печать это не влияет.",
+      },
+      "bambu-cloud-captcha": {
+        name: "Bambu Cloud требует пройти CAPTCHA",
+        cause: "Защита Bambu от злоупотреблений проверяет эту сеть, поэтому вход в Bambu Cloud завершить невозможно. Это связано с публичным IP-адресом, а не с вашей учётной записью или этой установкой.",
+        fix: "Подождите: обычно проверка снимается за несколько часов, а повторные попытки входа её продлевают. Пока что войдите с помощью токена доступа из сессии браузера.",
       },
       "mqtt-connection-flapping": {
         name: "Подключение к принтеру постоянно обрывается",
@@ -6631,6 +6639,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: "Не удалось встроить 3D-предпросмотр",
+    blockedBody: "Bambuddy разрешает этой странице показывать просмотрщик G-code встроенным, но что-то между браузером и Bambuddy это запрещает — обычно обратный прокси или расширение безопасности, отправляющее собственный заголовок фрейма. Открытие просмотрщика в отдельной вкладке не затрагивается.",
+    unavailableTitle: "3D-предпросмотр недоступен",
+    unavailableBody: "Bambuddy не смог отдать файлы просмотрщика G-code. Обычно это значит, что в установке отсутствует каталог gcode_viewer; об этом также сообщает журнал запуска.",
+    problemDetail: "Сообщение сервера: {{detail}}",
+    openInNewTab: "Открыть просмотрщик в новой вкладке",
     back: "Назад",
     backToArchives: "Вернуться в архив печати",
     backToFiles: "Вернуться в файловый менеджер",

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

@@ -3600,6 +3600,8 @@ export default {
       verifyButton: 'Doğrula',
       setTokenButton: 'Belirteç Ayarla',
       useToken: 'Erişim belirteci kullan',
+      captchaTitle: 'Bambu Cloud CAPTCHA istiyor',
+      captchaBody: 'Bambu, oturum açmayı kabul etmeden önce ağınızdan CAPTCHA doğrulaması istiyor ve bu doğrulama Bambuddy üzerinden yanıtlanamaz. Sorun e-postanız veya parolanız değil. Engel genel IP adresinize bağlıdır ve genellikle birkaç saat içinde kendiliğinden kalkar; sürekli yeniden denemek süreyi uzatır. Şimdi oturum açmak için tarayıcı oturumundan alınan bir erişim belirteci kullanın.',
       useEmail: 'E-posta ile giriş yap',
       toast: {
         loggedIn: 'Başarıyla giriş yapıldı',
@@ -6662,6 +6664,7 @@ export default {
         title: 'Dosya aktarım portu (FTPS 990)',
         pass: 'Erişilebilir — baskı dosyaları gönderme çalışacak.',
         warn: 'Port 990 erişilemez. İzleme yine çalışabilir, ancak yazıcıya baskı gönderme başarısız olacak. Port 990\'ın engellenmediğinden emin olun.',
+        warn_no_tls: 'Port 990 açık, ancak yazıcının dosya hizmeti TLS el sıkışmasını tamamlamıyor. Baskı dosyaları, kapak görselleri ve timelapse videoları alınamadığı için arşivler boş kalır. Yazıcıyı yeniden başlatın — portun engelini kaldırmak işe yaramaz.',
       },
       external_storage: {
         title: 'Gönderilen dosyaları harici depolamada sakla (kurulum adımı 4)',
@@ -6736,8 +6739,13 @@ export default {
       },
       'ftp-ssl-error': {
         name: 'Güvenli dosya aktarım el sıkışması başarısız',
-        cause: 'Yazıcının dosya aktarım sunucusuyla TLS el sıkışması başarısız oldu. Bu genellikle bir güvenlik duvarı veya eski yazıcı firmware\'idir.',
-        fix: 'Yazıcı firmware\'ini güncelleyin ve port 990\'daki bağlantıyı hiçbir güvenlik duvarı veya proxy\'nin engellemediğini kontrol edin.',
+        cause: 'Yazıcının dosya hizmeti 990 numaralı bağlantı noktasında TLS olmadan yanıt verdi. Dosya sunucusu takılmış durumda: bu, güvenlik duvarı veya firmware sorunu değil, yazıcı kaynaklı bir arızadır.',
+        fix: 'Yazıcıyı yeniden başlatın. O zamana kadar baskı dosyaları, kapak görselleri ve timelapse videoları alınamaz; baskının kendisi etkilenmez.',
+      },
+      'bambu-cloud-captcha': {
+        name: 'Bambu Cloud CAPTCHA istiyor',
+        cause: 'Bambu\'nun kötüye kullanım koruması bu ağı denetliyor, bu yüzden hiçbir Bambu Cloud oturum açma işlemi tamamlanamıyor. Bu, hesabınıza veya bu kuruluma değil, genel IP adresine bağlıdır.',
+        fix: 'Bekleyin: genellikle birkaç saat içinde kalkar ve tekrarlanan denemeler süreyi uzatır. Bu sırada tarayıcı oturumundan alınan bir erişim belirteciyle bağlanın.',
       },
       'mqtt-connection-flapping': {
         name: 'Yazıcı bağlantısı sürekli düşüyor',
@@ -6945,6 +6953,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: '3D önizleme gömülemedi',
+    blockedBody: 'Bambuddy bu sayfanın G-code görüntüleyiciyi gömülü göstermesine izin veriyor, ancak tarayıcınızla Bambuddy arasındaki bir şey bunu reddediyor — genellikle kendi çerçeve başlığını gönderen bir ters proxy veya güvenlik eklentisi. Görüntüleyiciyi kendi sekmesinde açmak bundan etkilenmez.',
+    unavailableTitle: '3D önizleme kullanılamıyor',
+    unavailableBody: 'Bambuddy, G-code görüntüleyicinin dosyalarını sunamadı. Bu genellikle kurulumda gcode_viewer dizininin eksik olduğu anlamına gelir; başlangıç günlüğü de bunu belirtir.',
+    problemDetail: 'Sunucunun bildirdiği: {{detail}}',
+    openInNewTab: 'Görüntüleyiciyi yeni sekmede aç',
     back: 'Geri',
     backToArchives: 'Baskı Arşivlerine Dön',
     backToFiles: 'Dosya Yöneticisine Dön',

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

@@ -3625,6 +3625,8 @@ export default {
       verifyButton: "Підтвердити",
       setTokenButton: "Установити токен",
       useToken: "Натомість використати токен доступу",
+      captchaTitle: "Bambu Cloud вимагає пройти CAPTCHA",
+      captchaBody: "Bambu вимагає від вашої мережі пройти CAPTCHA, перш ніж прийняти вхід, і відповісти на цю перевірку з Bambuddy неможливо. Річ не в пошті та не в паролі. Блокування прив'язане до вашої публічної IP-адреси і зазвичай зникає саме протягом кількох годин, а повторні спроби лише подовжують його. Щоб увійти зараз, скористайтеся токеном доступу із сеансу браузера.",
       useEmail: "Увійти за допомогою електронної пошти",
       toast: {
         loggedIn: "Успішно ввійшли",
@@ -6766,6 +6768,7 @@ export default {
         title: "Порт передачі файлів (FTPS 990)",
         pass: "Доступний — файли друку можна буде надсилати.",
         warn: "Порт 990 недоступний. Моніторинг може й надалі працювати, але надсилання завдань друку на принтер завершуватиметься помилкою. Переконайтеся, що порт 990 не заблоковано.",
+        warn_no_tls: "Порт 990 відкритий, але файлова служба принтера не завершує рукостискання TLS. Файли друку, обкладинки та таймлапси отримати неможливо, тому архіви залишаються порожніми. Перезавантажте принтер — розблокування порту не допоможе.",
       },
       external_storage: {
         title: "Зберігання надісланих файлів у зовнішньому сховищі (крок установлення 4)",
@@ -6840,8 +6843,13 @@ export default {
       },
       'ftp-ssl-error': {
         name: "Помилка захищеного з’єднання для передавання файлів",
-        cause: "Не вдалося виконати TLS-узгодження із сервером передавання файлів принтера. Частою причиною є брандмауер або застаріла прошивка принтера.",
-        fix: "Оновіть прошивку принтера та переконайтеся, що брандмауер або проксі-сервер не перехоплюють з’єднання через порт 990.",
+        cause: "Файлова служба принтера відповіла на порту 990 без TLS. Її файловий сервер завис — це несправність самого принтера, а не брандмауера чи прошивки.",
+        fix: "Перезапустіть принтер. До того часу файли друку, обкладинки та таймлапси отримати неможливо, але на сам друк це не впливає.",
+      },
+      'bambu-cloud-captcha': {
+        name: "Bambu Cloud вимагає пройти CAPTCHA",
+        cause: "Захист Bambu від зловживань перевіряє цю мережу, тож завершити вхід у Bambu Cloud неможливо. Це пов'язано з публічною IP-адресою, а не з вашим обліковим записом чи цією інсталяцією.",
+        fix: "Зачекайте: зазвичай перевірка зникає за кілька годин, а повторні спроби входу її подовжують. Тим часом увійдіть за допомогою токена доступу із сеансу браузера.",
       },
       'mqtt-connection-flapping': {
         name: "Підключення до принтера постійно падає",
@@ -7049,6 +7057,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: "Не вдалося вбудувати 3D-перегляд",
+    blockedBody: "Bambuddy дозволяє цій сторінці показувати переглядач G-code вбудованим, але щось між браузером і Bambuddy це відхиляє — зазвичай зворотний проксі або розширення безпеки, яке надсилає власний заголовок фрейму. Відкриття переглядача в окремій вкладці це не зачіпає.",
+    unavailableTitle: "3D-перегляд недоступний",
+    unavailableBody: "Bambuddy не зміг віддати файли переглядача G-code. Зазвичай це означає, що в установці бракує каталогу gcode_viewer; журнал запуску також про це повідомляє.",
+    problemDetail: "Повідомлення сервера: {{detail}}",
+    openInNewTab: "Відкрити переглядач у новій вкладці",
     back: "Назад",
     backToArchives: "Назад до друку архівів",
     backToFiles: "Назад до файлового менеджера",

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

@@ -3585,6 +3585,8 @@ export default {
       verifyButton: '验证',
       setTokenButton: '设置令牌',
       useToken: '改用访问令牌',
+      captchaTitle: 'Bambu Cloud 要求进行 CAPTCHA 验证',
+      captchaBody: 'Bambu 在接受登录之前要求你的网络通过 CAPTCHA 验证,而该验证无法在 Bambuddy 中完成。这与你的邮箱和密码无关。限制与你的公网 IP 地址绑定,通常几小时后会自动解除;反复重试只会延长限制。若要立即登录,请改用从浏览器会话中获取的访问令牌。',
       useEmail: '改用邮箱登录',
       toast: {
         loggedIn: '登录成功',
@@ -6711,6 +6713,7 @@ export default {
         title: '文件传输端口(FTPS 990)',
         pass: '可达 — 发送打印文件将正常工作。',
         warn: '端口 990 不可达。监控可能仍然有效,但向打印机发送打印任务将失败。请确保端口 990 未被阻止。',
+        warn_no_tls: '端口 990 已开放,但打印机的文件服务未能完成 TLS 握手。无法获取打印文件、封面图和延时视频,因此归档会保持为空。请重启打印机 — 解除端口封锁无济于事。',
       },
       external_storage: {
         title: '将发送的文件存储在外部存储中(安装步骤 4)',
@@ -6785,8 +6788,13 @@ export default {
       },
       'ftp-ssl-error': {
         name: '安全文件传输握手失败',
-        cause: '与打印机文件传输服务器的 TLS 握手失败。通常是防火墙或打印机固件过旧所致。',
-        fix: '请更新打印机固件,并检查没有防火墙或代理拦截 990 端口上的连接。',
+        cause: '打印机的文件服务在 990 端口上没有使用 TLS 响应。其文件服务器已卡死,这是打印机侧的故障,与防火墙或固件无关。',
+        fix: '请重启打印机。在此之前无法获取打印文件、封面图和延时视频;打印本身不受影响。',
+      },
+      'bambu-cloud-captcha': {
+        name: 'Bambu Cloud 要求进行 CAPTCHA 验证',
+        cause: 'Bambu 的防滥用机制正在验证此网络,因此无法完成 Bambu Cloud 登录。这与公网 IP 地址有关,与你的账号或此安装无关。',
+        fix: '请等待:通常几小时后会自动解除,反复登录只会延长时间。在此期间,可使用从浏览器会话中获取的访问令牌登录。',
       },
       'mqtt-connection-flapping': {
         name: '打印机连接反复断开',
@@ -6993,6 +7001,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: '无法嵌入 3D 预览',
+    blockedBody: 'Bambuddy 允许此页面内嵌显示 G-code 查看器,但浏览器与 Bambuddy 之间的某个环节拒绝了它 — 通常是发送自有框架标头的反向代理或安全插件。在独立标签页中打开查看器不受影响。',
+    unavailableTitle: '3D 预览不可用',
+    unavailableBody: 'Bambuddy 无法提供 G-code 查看器的文件。这通常表示安装中缺少 gcode_viewer 目录;启动日志中也会有相应记录。',
+    problemDetail: '服务器报告:{{detail}}',
+    openInNewTab: '在新标签页中打开查看器',
     back: '返回',
     backToArchives: '返回打印归档',
     backToFiles: '返回文件管理器',

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

@@ -3585,6 +3585,8 @@ export default {
       verifyButton: '驗證',
       setTokenButton: '設定權杖',
       useToken: '改用存取權杖',
+      captchaTitle: 'Bambu Cloud 要求進行 CAPTCHA 驗證',
+      captchaBody: 'Bambu 在接受登入之前要求你的網路通過 CAPTCHA 驗證,而該驗證無法在 Bambuddy 中完成。這與你的電子郵件和密碼無關。限制與你的公開 IP 位址綁定,通常幾小時後會自動解除;反覆重試只會延長限制。若要立即登入,請改用從瀏覽器工作階段取得的存取權杖。',
       useEmail: '改用信箱登入',
       toast: {
         loggedIn: '登入成功',
@@ -6711,6 +6713,7 @@ export default {
         title: '檔案傳輸連接埠(FTPS 990)',
         pass: '可達 — 傳送列印檔案將正常運作。',
         warn: '連接埠 990 無法連線。監控可能仍然有效,但向印表機傳送列印工作將失敗。請確保連接埠 990 未被封鎖。',
+        warn_no_tls: '連接埠 990 已開放,但印表機的檔案服務未能完成 TLS 交握。無法取得列印檔案、封面圖與縮時影片,因此封存會維持空白。請重新啟動印表機 — 解除連接埠封鎖並無幫助。',
       },
       external_storage: {
         title: '將傳送的檔案儲存在外部儲存中(安裝步驟 4)',
@@ -6785,8 +6788,13 @@ export default {
       },
       'ftp-ssl-error': {
         name: '安全檔案傳輸交握失敗',
-        cause: '與印表機檔案傳輸伺服器的 TLS 交握失敗。通常是防火牆或印表機韌體過舊所致。',
-        fix: '請更新印表機韌體,並檢查沒有防火牆或代理伺服器攔截 990 連接埠上的連線。',
+        cause: '印表機的檔案服務在 990 連接埠上未使用 TLS 回應。其檔案伺服器已卡住,這是印表機端的故障,與防火牆或韌體無關。',
+        fix: '請重新啟動印表機。在此之前無法取得列印檔案、封面圖和縮時影片;列印本身不受影響。',
+      },
+      'bambu-cloud-captcha': {
+        name: 'Bambu Cloud 要求進行 CAPTCHA 驗證',
+        cause: 'Bambu 的防濫用機制正在驗證此網路,因此無法完成 Bambu Cloud 登入。這與公開 IP 位址有關,與你的帳號或此安裝無關。',
+        fix: '請等待:通常幾小時後會自動解除,反覆登入只會延長時間。在此期間,可使用從瀏覽器工作階段取得的存取權杖登入。',
       },
       'mqtt-connection-flapping': {
         name: '印表機連線反覆中斷',
@@ -6993,6 +7001,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: '無法嵌入 3D 預覽',
+    blockedBody: 'Bambuddy 允許此頁面內嵌顯示 G-code 檢視器,但瀏覽器與 Bambuddy 之間的某個環節拒絕了它 — 通常是傳送自有框架標頭的反向代理或安全外掛。在獨立分頁中開啟檢視器不受影響。',
+    unavailableTitle: '3D 預覽無法使用',
+    unavailableBody: 'Bambuddy 無法提供 G-code 檢視器的檔案。這通常表示安裝中缺少 gcode_viewer 目錄;啟動記錄中也會有相應紀錄。',
+    problemDetail: '伺服器回報:{{detail}}',
+    openInNewTab: '在新分頁中開啟檢視器',
     back: '返回',
     backToArchives: '返回列印歸檔',
     backToFiles: '返回檔案管理器',

+ 22 - 0
frontend/src/index.css

@@ -26,6 +26,28 @@
 /* Enable class-based dark mode for Tailwind v4 */
 @custom-variant dark (&:where(.dark, .dark *));
 
+/* Restore the pointer cursor on interactive controls (#2791).  Tailwind v3's
+   Preflight set `button { cursor: pointer }`; v4 dropped it to match the
+   browser default of `cursor: default`, so every button in the app looked
+   unclickable unless someone remembered to add `cursor-pointer` by hand.
+   Only a handful of the ~930 buttons did, which is why the UI felt
+   inconsistent rather than uniformly wrong.
+
+   This lives in `base`, the lowest of Tailwind's cascade layers, so the
+   `cursor-not-allowed` / `disabled:cursor-*` utilities dotted around the
+   codebase still win.  The `:not(:disabled)` guard covers the elements that
+   are disabled without also carrying such a utility. */
+@layer base {
+  button:not(:disabled),
+  select:not(:disabled),
+  summary,
+  input[type="checkbox"]:not(:disabled),
+  input[type="radio"]:not(:disabled),
+  [role="button"]:not([aria-disabled="true"]) {
+    cursor: pointer;
+  }
+}
+
 @theme {
   /* Accent colors - use CSS variables for theming */
   --color-bambu-green: var(--accent);

+ 4 - 2
frontend/src/pages/ArchivesPage.tsx

@@ -740,7 +740,8 @@ function ArchiveCard({
     { label: '', divider: true, onClick: () => {} },
     {
       label: archive.is_favorite ? t('archives.menu.removeFromFavorites') : t('archives.menu.addToFavorites'),
-      icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
+      // Preview the favourited state on hover so the row reads as clickable (#2791).
+      icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : canModify('archives', 'update', archive.created_by_id) ? 'group-hover:text-yellow-400' : ''}`} />,
       onClick: () => favoriteMutation.mutate(),
       disabled: !canModify('archives', 'update', archive.created_by_id),
       title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
@@ -2138,7 +2139,8 @@ function ArchiveListRow({
     { label: '', divider: true, onClick: () => {} },
     {
       label: archive.is_favorite ? t('archives.menu.removeFromFavorites') : t('archives.menu.addToFavorites'),
-      icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
+      // Preview the favourited state on hover so the row reads as clickable (#2791).
+      icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : canModify('archives', 'update', archive.created_by_id) ? 'group-hover:text-yellow-400' : ''}`} />,
       onClick: () => favoriteMutation.mutate(),
       disabled: !canModify('archives', 'update', archive.created_by_id),
       title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,

+ 92 - 19
frontend/src/pages/GCodeViewerPage.tsx

@@ -1,16 +1,62 @@
+import { useEffect, useState } from 'react';
 import { useNavigate, useSearchParams } from 'react-router-dom';
-import { ArrowLeft } from 'lucide-react';
+import { ArrowLeft, ExternalLink, ShieldAlert } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
+import { findFramingRefusal, type FrameProblem } from '../utils/framing';
 
 export function GCodeViewerPage() {
   const navigate = useNavigate();
   const [searchParams] = useSearchParams();
   const { t } = useTranslation();
+  const [problem, setProblem] = useState<FrameProblem | null>(null);
+
+  // Forward the outer page's query string (e.g. ?archive=82) to the iframe so
+  // the adapter inside can pick up the archive to load. The iframe itself must
+  // keep the trailing slash on /gcode-viewer/ so it hits the raw-viewer route;
+  // the outer SPA URL uses no trailing slash so a reload falls through to the
+  // SPA catch-all and keeps the Bambuddy layout shell.
+  const iframeSrc = `/gcode-viewer/${window.location.search}`;
+  const embedded = window !== window.top;
+
+  // A frame refused by X-Frame-Options / frame-ancestors still fires `onLoad` —
+  // the browser commits its own "refused to connect" error page — so the iframe
+  // itself cannot tell us anything. Ask for the same URL directly instead: it is
+  // same-origin, so every response header is readable, and it travels through
+  // whatever proxy the browser reaches Bambuddy by. The iframe is rendered
+  // straight away regardless and only replaced if this comes back refusing,
+  // which keeps the working case exactly as fast as before.
+  useEffect(() => {
+    if (embedded) return;
+    const controller = new AbortController();
+    (async () => {
+      try {
+        const response = await fetch(iframeSrc, {
+          credentials: 'same-origin',
+          signal: controller.signal,
+        });
+        if (!response.ok) {
+          setProblem({ kind: 'unavailable', detail: `HTTP ${response.status}` });
+          return;
+        }
+        const refusal = findFramingRefusal(
+          response.headers.get('x-frame-options'),
+          response.headers.get('content-security-policy'),
+          window.location.origin,
+        );
+        if (refusal) setProblem({ kind: 'blocked', detail: refusal });
+      } catch {
+        // Aborted, offline, or the probe itself was blocked. The iframe stays;
+        // guessing at a cause we have no evidence for would be worse than the
+        // browser's own error page.
+      }
+    })();
+    return () => controller.abort();
+  }, [iframeSrc, embedded]);
 
   // Safety guard: if this React app is itself inside an iframe (e.g. the
   // StaticFiles mount isn't registered and serve_spa returned us here),
   // don't render another iframe — that would create an infinite loop.
-  if (window !== window.top) {
+  if (embedded) {
     return (
       <div style={{ padding: 32, color: '#f88' }}>
         GCode viewer static files not found. Check that the{' '}
@@ -39,13 +85,6 @@ export function GCodeViewerPage() {
     }
   };
 
-  // Forward the outer page's query string (e.g. ?archive=82) to the iframe so
-  // the adapter inside can pick up the archive to load. The iframe itself must
-  // keep the trailing slash on /gcode-viewer/ so it hits the raw-viewer route;
-  // the outer SPA URL uses no trailing slash so a reload falls through to the
-  // SPA catch-all and keeps the Bambuddy layout shell.
-  const iframeSrc = `/gcode-viewer/${window.location.search}`;
-
   return (
     // h-14 (3.5 rem) is the fixed header height defined in Layout.tsx.
     // Subtracting it prevents a double scrollbar inside the layout shell.
@@ -60,16 +99,50 @@ export function GCodeViewerPage() {
           {backLabel}
         </button>
       </div>
-      <iframe
-        src={iframeSrc}
-        title="GCode Viewer"
-        style={{
-          display: 'block',
-          width: '100%',
-          flex: 1,
-          border: 'none',
-        }}
-      />
+      {problem ? (
+        <div className="flex-1 overflow-y-auto p-6">
+          <div role="alert" className="max-w-2xl mx-auto p-4 rounded-lg border border-amber-500/40 bg-amber-500/10">
+            <div className="flex items-start gap-3">
+              <ShieldAlert className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
+              <div className="min-w-0">
+                <p className="text-sm font-medium text-amber-300">
+                  {problem.kind === 'blocked'
+                    ? t('gcodeViewer.blockedTitle')
+                    : t('gcodeViewer.unavailableTitle')}
+                </p>
+                <p className="text-xs text-bambu-gray mt-1">
+                  {problem.kind === 'blocked'
+                    ? t('gcodeViewer.blockedBody')
+                    : t('gcodeViewer.unavailableBody')}
+                </p>
+                <p className="text-xs text-bambu-gray mt-2 font-mono break-all">
+                  {t('gcodeViewer.problemDetail', { detail: problem.detail })}
+                </p>
+                <a
+                  href={iframeSrc}
+                  target="_blank"
+                  rel="noreferrer"
+                  className="mt-3 inline-flex items-center gap-1 text-xs text-bambu-green hover:underline"
+                >
+                  <ExternalLink className="w-3 h-3" />
+                  {t('gcodeViewer.openInNewTab')}
+                </a>
+              </div>
+            </div>
+          </div>
+        </div>
+      ) : (
+        <iframe
+          src={iframeSrc}
+          title="GCode Viewer"
+          style={{
+            display: 'block',
+            width: '100%',
+            flex: 1,
+            border: 'none',
+          }}
+        />
+      )}
     </div>
   );
 }

+ 42 - 4
frontend/src/pages/ProfilesPage.tsx

@@ -41,6 +41,7 @@ import {
   Minus as MinusIcon,
   Plus as PlusIcon,
   HardDrive,
+  ShieldAlert,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { formatRelativeTime } from '../utils/date';
@@ -95,7 +96,7 @@ function isUserPreset(settingId: string): boolean {
 // LOGIN FORM
 // ============================================================================
 
-function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
+export function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
   const { showToast } = useToast();
   const [step, setStep] = useState<LoginStep>('email');
   const [email, setEmail] = useState('');
@@ -105,13 +106,20 @@ function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
   const [region, setRegion] = useState('global');
   const [verificationType, setVerificationType] = useState<'email' | 'totp' | null>(null);
   const [tfaKey, setTfaKey] = useState<string | null>(null);
+  // Bambu is challenging this network with a CAPTCHA (#2790). A toast is the
+  // wrong shape for it: nothing the user types will help, the remedy is to wait
+  // or switch to a token, and both need to stay on screen while they read.
+  const [captchaBlocked, setCaptchaBlocked] = useState(false);
 
   const loginMutation = useMutation({
     mutationFn: () => api.cloudLogin(email, password, region),
     onSuccess: (result) => {
+      setCaptchaBlocked(result.reason === 'captcha');
       if (result.success) {
         showToast(t('profiles.login.toast.loggedIn'));
         onSuccess();
+      } else if (result.reason === 'captcha') {
+        return; // The panel below says everything a toast could, and stays put.
       } else if (result.needs_verification) {
         setVerificationType(result.verification_type || 'email');
         setTfaKey(result.tfa_key || null);
@@ -125,20 +133,27 @@ function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
         showToast(result.message, 'error');
       }
     },
-    onError: (error: Error) => showToast(error.message, 'error'),
+    onError: (error: Error) => {
+      setCaptchaBlocked(false);
+      showToast(error.message, 'error');
+    },
   });
 
   const verifyMutation = useMutation({
     mutationFn: () => api.cloudVerify(email, code, tfaKey || undefined, region),
     onSuccess: (result) => {
+      setCaptchaBlocked(result.reason === 'captcha');
       if (result.success) {
         showToast(t('profiles.login.toast.loggedIn'));
         onSuccess();
-      } else {
+      } else if (result.reason !== 'captcha') {
         showToast(result.message, 'error');
       }
     },
-    onError: (error: Error) => showToast(error.message, 'error'),
+    onError: (error: Error) => {
+      setCaptchaBlocked(false);
+      showToast(error.message, 'error');
+    },
   });
 
   const tokenMutation = useMutation({
@@ -170,6 +185,29 @@ function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
           <p className="text-sm text-bambu-gray mt-1">{t('profiles.login.subtitle')}</p>
         </div>
 
+        {captchaBlocked && step !== 'token' && (
+          <div role="alert" className="mb-4 p-3 rounded-lg border border-amber-500/40 bg-amber-500/10">
+            <div className="flex items-start gap-2">
+              <ShieldAlert className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
+              <div>
+                <p className="text-sm font-medium text-amber-300">{t('profiles.login.captchaTitle')}</p>
+                <p className="text-xs text-bambu-gray mt-1">{t('profiles.login.captchaBody')}</p>
+                <button
+                  type="button"
+                  onClick={() => {
+                    setCaptchaBlocked(false);
+                    setStep('token');
+                  }}
+                  className="mt-2 text-xs text-bambu-green hover:underline flex items-center gap-1"
+                >
+                  <Key className="w-3 h-3" />
+                  {t('profiles.login.useToken')}
+                </button>
+              </div>
+            </div>
+          </div>
+        )}
+
         <form onSubmit={handleSubmit} className="space-y-4">
           {step === 'email' && (
             <>

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

@@ -466,7 +466,7 @@ function SortableQueueItem({
         ${isPrinting ? 'border-blue-500/30 bg-gradient-to-r from-blue-500/5 to-transparent' : ''}
         ${isSelected && isMobileSelectable ? 'sm:border-bambu-dark-tertiary border-bambu-green/40' : ''}
         ${!isSelected && !isPrinting ? 'border-bambu-dark-tertiary hover:border-bambu-dark-tertiary/80' : ''}
-        ${isMobileSelectable ? 'sm:cursor-default' : ''}
+        ${isMobileSelectable ? 'cursor-pointer sm:cursor-default' : ''}
       `}
       onClick={isMobileSelectable ? () => {
         if (window.innerWidth < 640) onToggleSelect();

+ 65 - 0
frontend/src/utils/framing.ts

@@ -0,0 +1,65 @@
+/**
+ * Reading a response's framing headers, for the embedded G-code viewer (#2787).
+ *
+ * The viewer is the only part of Bambuddy that embeds a Bambuddy page in a
+ * frame, so it is the only part a proxy-added framing header can break — and it
+ * breaks with the browser's own error page, which says nothing about what was
+ * refused or by whom.
+ */
+
+/** Why the viewer could not be shown inline, with the evidence that says so. */
+export type FrameProblem =
+  | { kind: 'blocked'; detail: string }
+  | { kind: 'unavailable'; detail: string };
+
+/**
+ * Decide whether a response's framing headers allow `origin` to embed it.
+ *
+ * Returns the offending header verbatim when embedding is refused, or null when
+ * it is allowed. Bambuddy's own headers always allow it (`frame-ancestors
+ * 'self'` plus `X-Frame-Options: SAMEORIGIN`, set in `main.py`), so a refusal
+ * means something between the browser and Bambuddy — a reverse proxy, a
+ * security add-on, an auth gateway — added a stricter one.
+ *
+ * `frame-ancestors` wins outright when present: per CSP the browser must ignore
+ * `X-Frame-Options` entirely in that case, so reading both would blame a
+ * proxy-added `X-Frame-Options: DENY` the browser never consulted. Multiple CSP
+ * headers are *intersected*, and `fetch` joins them into one comma-separated
+ * string, so every `frame-ancestors` occurrence has to permit us — not just the
+ * first one.
+ */
+export function findFramingRefusal(
+  xFrameOptions: string | null,
+  contentSecurityPolicy: string | null,
+  origin: string,
+): string | null {
+  const csp = contentSecurityPolicy ?? '';
+  const directives = [...csp.matchAll(/(?:^|[;,])\s*frame-ancestors\s+([^;,]*)/gi)];
+  if (directives.length > 0) {
+    const self = origin.toLowerCase();
+    for (const [, raw] of directives) {
+      const value = raw.trim();
+      const sources = value.toLowerCase().split(/\s+/).filter(Boolean);
+      const permitsUs = sources.some(
+        (source) =>
+          source === '*' ||
+          source === "'self'" ||
+          source === self ||
+          source === self.replace(/^https?:\/\//, ''),
+      );
+      if (!permitsUs) return `Content-Security-Policy: frame-ancestors ${value}`;
+    }
+    return null;
+  }
+
+  // No frame-ancestors anywhere: the legacy header governs. Anything other than
+  // a single SAMEORIGIN refuses us — DENY, ALLOW-FROM, or the conflicting
+  // "SAMEORIGIN, DENY" that appears when a proxy appends a second copy.
+  const legacy = (xFrameOptions ?? '')
+    .split(',')
+    .map((value) => value.trim().toLowerCase())
+    .filter(Boolean);
+  if (legacy.length === 0) return null;
+  if (legacy.length === 1 && legacy[0] === 'sameorigin') return null;
+  return `X-Frame-Options: ${xFrameOptions}`;
+}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CDkM7wuh.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/index-DJ8Q_OV9.css


File diff suppressed because it is too large
+ 1 - 0
static/assets/index-ud1tvgv1.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CBRJgDPF.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DJ8Q_OV9.css">
+    <script type="module" crossorigin src="/assets/index-CDkM7wuh.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-ud1tvgv1.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff