Procházet zdrojové kódy

Explain a print that never reached the printer's card, instead of sweeping for it (#2780)

    Bambuddy reads a print's 3MF, cover and timelapse over FTPS on port 990,
    which on every Bambu model serves external storage only. Under some
    configurations H2-series and P2S firmware keeps the sliced file on
    internal storage, where Bambu Studio put it over the port-6000 service,
    and then no path on 990 can find it.

    The print command has always said which of the two it used -- `url` reads
    ftp://<name> or brtc://emmc/<name>. We discarded it and swept anyway:
    ~110 connections per print, all certain to fail, ending in an archive
    card with nothing on it and no stated reason. In the reporter's bundle
    all 35 dispatches to their H2C and P2S said internal storage, all 25 to
    their X1C said external, and all 44 empty cards belonged to the first two.

    Read the field, skip the sweep when it cannot succeed, and record which
    reason applied. A printer that uses the card is unaffected, and so is one
    we have no answer for -- silence is not evidence, and reading it as bad
    news would break archives that work today.

    The answer is held per print and dropped when that print ends, rather
    than kept as a standing fact about the printer. Plenty of prints never
    announce themselves: 14 of the 79 print starts in that bundle arrived
    with nothing on the request topic, started from the printer's own screen
    or picked up after a restart. Left standing, one slicer print to internal
    storage would suppress the lookup for every screen-started print after
    it, on a printer whose files really are on the card. The sticky reading
    is kept for the connection diagnostic alone, which is run after the print
    that prompted it and would otherwise have nothing to report.

    Two things that pointed the wrong way go with it. The archives banner
    told everyone to enable "Store sent files on external storage"; the
    reporter had it on for the whole three weeks and it would not have
    helped. The diagnostic passed a printer whose slot was empty, because it
    read only the toggle -- an empty slot is now a failure naming the slot,
    and a printer that has storage and still used its own is a warning. On
    P1-series that empty-slot failure yields to the existing unsupported-model
    skip: the toggle cannot be switched on there at all, so telling the
    operator to insert a card would promise a fix inserting a card does not
    deliver (#2524).

    Also close FTP sockets on the failure paths, which dropped them for the
    garbage collector -- 1813 in a day in that bundle -- and drop the advice
    to restart the printer, which the reporter tried twice while a single
    manual connection to the same printer handshook cleanly.

    This does not make the affected prints archive in full; that needs the
    port-6000 protocol tracked in #2762.
maziggy před 3 týdny
rodič
revize
b83eea07cd
33 změnil soubory, kde provedl 1731 přidání a 56 odebrání
  1. 39 11
      backend/app/api/routes/archives.py
  2. 15 1
      backend/app/api/routes/printers.py
  3. 45 3
      backend/app/main.py
  4. 60 15
      backend/app/services/bambu_ftp.py
  5. 41 0
      backend/app/services/bambu_mqtt.py
  6. 147 0
      backend/app/services/print_storage.py
  7. 33 2
      backend/app/services/printer_diagnostic.py
  8. 97 5
      backend/tests/integration/test_archives_api.py
  9. 98 1
      backend/tests/integration/test_printers_api.py
  10. 9 3
      backend/tests/unit/services/test_bambu_ftp.py
  11. 133 0
      backend/tests/unit/services/test_ftp_failed_connect_cleanup_2780.py
  12. 97 1
      backend/tests/unit/services/test_printer_diagnostic.py
  13. 203 0
      backend/tests/unit/services/test_project_url_capture_2780.py
  14. 219 0
      backend/tests/unit/test_print_start_skips_unreachable_storage_2780.py
  15. 257 0
      backend/tests/unit/test_print_storage_2780.py
  16. 103 0
      frontend/src/__tests__/pages/ArchivesNo3MFBanner.test.tsx
  17. 4 1
      frontend/src/api/client.ts
  18. 7 0
      frontend/src/i18n/locales/de.ts
  19. 7 0
      frontend/src/i18n/locales/en.ts
  20. 7 0
      frontend/src/i18n/locales/es.ts
  21. 7 0
      frontend/src/i18n/locales/fr.ts
  22. 7 0
      frontend/src/i18n/locales/it.ts
  23. 7 0
      frontend/src/i18n/locales/ja.ts
  24. 8 1
      frontend/src/i18n/locales/ko.ts
  25. 7 0
      frontend/src/i18n/locales/pt-BR.ts
  26. 7 0
      frontend/src/i18n/locales/ru.ts
  27. 7 0
      frontend/src/i18n/locales/tr.ts
  28. 7 0
      frontend/src/i18n/locales/uk.ts
  29. 7 0
      frontend/src/i18n/locales/zh-CN.ts
  30. 7 0
      frontend/src/i18n/locales/zh-TW.ts
  31. 38 11
      frontend/src/pages/ArchivesPage.tsx
  32. 0 0
      static/assets/index-CwdtRDU5.js
  33. 1 1
      static/index.html

+ 39 - 11
backend/app/api/routes/archives.py

@@ -31,6 +31,7 @@ from backend.app.schemas.slicer import SliceRequest
 from backend.app.services.archive import ArchiveService
 from backend.app.services.design_settings import overrides_from_config
 from backend.app.services.filament_requirements import annotate_rack_groups
+from backend.app.services.print_storage import REASON_INTERNAL_STORAGE, REASON_NO_EXTERNAL_STORAGE
 from backend.app.utils.http import build_content_disposition
 from backend.app.utils.safe_path import safe_join_under
 from backend.app.utils.threemf_tools import (
@@ -512,10 +513,23 @@ async def no_3mf_warning(
         )
     ),
 ):
-    """Whether to nudge the user about install step 4 ("Store sent files on
-    external storage"). True iff any archive in the last 30 days was created
-    via the no-3MF fallback path — that's the deterministic symptom of the
-    slicer-side variant of the setting being off.
+    """Whether to nudge the user about a print that archived without its 3MF,
+    and why. True iff any archive in the last 30 days was created via the
+    no-3MF fallback path.
+
+    Also returns ``reason``, because the advice differs and the original
+    single-cause wording sent people the wrong way. Historically the only
+    known cause was install step 4 ("Store sent files on external storage")
+    being off in the slicer, so the banner said so unconditionally. On
+    H2-series and P2S that advice is actively wrong: the setting is already on
+    and turning it on again changes nothing, because the printer keeps the
+    sliced file on internal storage that FTPS does not serve at all (#2780).
+
+    ``reason`` is the slug from :mod:`print_storage` when we recorded one,
+    else None for the original slicer-setting case. When archives disagree the
+    most specific known reason wins — one printer storing internally is a real
+    finding worth explaining, and it should not be masked by another printer's
+    plain missing-file fallback.
 
     Complements the connection-diagnostic ``external_storage`` check, which
     only catches the printer-side variant of the setting. On older slicers
@@ -536,10 +550,24 @@ async def no_3mf_warning(
     if user is not None and not can_read_all:
         conditions.append(PrintArchive.created_by_id == user.id)
     result = await db.execute(select(PrintArchive.extra_data).where(*conditions))
+    reasons: set[str] = set()
+    has_fallback = False
     for (extra_data,) in result.all():
-        if extra_data and extra_data.get("no_3mf_available"):
-            return {"has_fallback": True}
-    return {"has_fallback": False}
+        if not extra_data or not extra_data.get("no_3mf_available"):
+            continue
+        has_fallback = True
+        reason = extra_data.get("no_3mf_reason")
+        if reason:
+            reasons.add(reason)
+    if not has_fallback:
+        return {"has_fallback": False, "reason": None}
+    # Most specific first. Archives predating this field carry no reason at
+    # all, so an install with one H2C and three older printers still gets the
+    # H2C explanation rather than the generic one.
+    for candidate in (REASON_INTERNAL_STORAGE, REASON_NO_EXTERNAL_STORAGE):
+        if candidate in reasons:
+            return {"has_fallback": True, "reason": candidate}
+    return {"has_fallback": True, "reason": None}
 
 
 @router.get("/slim", response_model=list[ArchiveSlim])
@@ -2347,14 +2375,14 @@ async def scan_timelapse(
     if not files:
         # "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.
+        # to come back as one 500 (#2780). Nothing here will work while the
+        # printer's file service is not answering over TLS, so say that rather
+        # than reporting an empty directory.
         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.",
+                "Bambuddy will try again shortly.",
             )
         raise HTTPException(404, "No timelapse directory found on the printer")
 

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

@@ -51,6 +51,7 @@ from backend.app.services.bambu_ftp import (
     get_storage_info_async,
     list_files_async,
 )
+from backend.app.services.print_storage import print_file_reachable_over_ftp
 from backend.app.services.printer_diagnostic import run_connection_diagnostic
 from backend.app.services.printer_manager import (
     display_temperatures,
@@ -1217,6 +1218,19 @@ async def _produce_cover_image(
             break
 
     if not downloaded:
+        # The cover lives inside the 3MF, so it is only reachable if the 3MF is.
+        # When the printer kept the print on internal storage there is nothing
+        # at any of these paths, and walking all sixteen of them just to end on
+        # a 404 that reads as "this print has no cover" helps nobody (#2780).
+        storage = print_file_reachable_over_ftp(printer_manager.get_status(printer_id))
+        if not storage.reachable:
+            _cover_404_cache.setdefault(printer_id, set()).add(cache_key)
+            raise HTTPException(
+                404,
+                f"The print file for '{subtask_name}' is not on storage Bambuddy can read over FTPS "
+                f"({storage.reason}), so it has no cover to extract.",
+            )
+
         logger.info(
             f"Trying to download cover for '{subtask_name}' from {printer.ip_address} (trying {len(remote_paths)} paths)"
         )
@@ -1234,7 +1248,7 @@ async def _produce_cover_image(
                 raise HTTPException(
                     503,
                     f"Printer {printer.ip_address} is not answering its file service over TLS. "
-                    "Restart the printer and try again.",
+                    "Bambuddy will try again shortly.",
                 )
             try:
                 downloaded = await download_file_try_paths_async(

+ 45 - 3
backend/app/main.py

@@ -108,6 +108,7 @@ from backend.app.services.notification_service import notification_service
 from backend.app.services.obico_detection import obico_detection_service
 from backend.app.services.print_cost_estimate import plate_scoped_run_estimate as _plate_scoped_run_estimate
 from backend.app.services.print_scheduler import scheduler as print_scheduler
+from backend.app.services.print_storage import external_storage_present, print_file_reachable_over_ftp
 from backend.app.services.printer_manager import (
     init_printer_connections,
     parse_plate_id,
@@ -3446,10 +3447,25 @@ async def on_print_start(printer_id: int, data: dict):
                 downloaded_filename = try_filename
                 break
 
+        # Does this printer keep the sliced file somewhere FTPS can reach? On
+        # H2-series and P2S the answer is routinely no — the file stays on
+        # internal eMMC and port 990 only ever serves external storage — and
+        # then the whole sweep below (six filenames x five directories x four
+        # retries, then the directory walk) is ~110 connections that cannot
+        # succeed. Skip it and say why (#2780).
+        storage = print_file_reachable_over_ftp(printer_manager.get_status(printer_id))
+        if not storage.reachable and not downloaded_filename:
+            logger.info(
+                "Skipping the 3MF lookup for printer %s: %s — the print file is not on storage "
+                "Bambuddy can read over FTPS, so no path would find it",
+                printer_id,
+                storage.reason,
+            )
+
         # Get FTP retry settings
         ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
 
-        for try_filename in possible_names if not downloaded_filename else []:
+        for try_filename in possible_names if not downloaded_filename and storage.reachable else []:
             if not try_filename.endswith(".3mf"):
                 continue
 
@@ -3527,7 +3543,12 @@ async def on_print_start(printer_id: int, data: dict):
         # 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):
+        if (
+            not downloaded_filename
+            and storage.reachable
+            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", "/"]
@@ -3742,7 +3763,15 @@ async def on_print_start(printer_id: int, data: dict):
                     subtask_id=subtask_id,
                     filament_type=mqtt_filament_meta.get("filament_type"),
                     filament_color=mqtt_filament_meta.get("filament_color"),
-                    extra_data={"no_3mf_available": True, "original_subtask": subtask_name, "_print_data": data},
+                    extra_data={
+                        "no_3mf_available": True,
+                        # Why the card is empty, when we know. The banner reads
+                        # this to stop telling H2/P2 owners to switch on a
+                        # setting that is already on and would not help (#2780).
+                        "no_3mf_reason": storage.reason,
+                        "original_subtask": subtask_name,
+                        "_print_data": data,
+                    },
                 )
 
                 db.add(fallback_archive)
@@ -3988,6 +4017,19 @@ async def _list_timelapse_videos(printer) -> tuple[list[dict], str | None]:
 
     logger = logging.getLogger(__name__)
 
+    # No card in the slot means no /timelapse to walk — four connections that
+    # can only fail, on a path whose failures are swallowed and so would go on
+    # costing time silently forever (#2780).
+    #
+    # ``getattr`` rather than ``printer.id``: every dereference below happens
+    # inside the loop's own try/except, so a caller that passed something
+    # unexpected used to get an empty listing rather than an exception. Keep
+    # that, instead of making this gate the first thing that can raise here.
+    printer_id = getattr(printer, "id", None)
+    if printer_id is not None and not external_storage_present(printer_manager.get_status(printer_id)):
+        logger.debug("[TIMELAPSE] Skipping the scan for printer %s: it reports no external storage", printer_id)
+        return [], None
+
     for timelapse_path in ["/timelapse", "/timelapse/video", "/record", "/recording"]:
         try:
             found_files = await list_files_async(

+ 60 - 15
backend/app/services/bambu_ftp.py

@@ -92,11 +92,20 @@ class DeleteResult(Enum):
 # 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).
+# something that is not a TLS record at all, so no path, retry or SSL option
+# gets further. 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).
+#
+# What it is NOT is a wedged file service, which is what this comment used to
+# claim. #2780's reporter power-cycled both affected printers and the state
+# survived it, and ``openssl s_client`` against the same port completes a clean
+# handshake and returns a valid certificate while Bambuddy is failing. The
+# leading theory is now a connection-count refusal — vsFTPd answers one in
+# cleartext, which is exactly this error to an implicit-TLS client, and answers
+# the global limit by accepting and never speaking, which is the handshake
+# timeout we also see. Unproven: confirming it needs a capture taken while a
+# printer is in the failing state.
 #
 # 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
@@ -324,41 +333,77 @@ class BambuFTPClient:
             return True
         except ftplib.error_perm as e:
             logger.warning("FTP connection permission error to %s: %s", self.ip_address, e)
-            self._ftp = None
+            self._abandon_connection()
             return False
         except TimeoutError as e:
             logger.warning("FTP connection timed out to %s: %s", self.ip_address, e)
-            self._ftp = None
+            self._abandon_connection()
             return False
         except ssl.SSLError as 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).
+            # 990 with something that isn't TLS. Say so once and stop knocking
+            # for a while (#2780).
+            #
+            # Deliberately no advice about what to do. This message used to
+            # tell the operator to restart the printer; #2780's reporter did
+            # that twice, to no effect, and a single manual connect to the
+            # same printer completes a clean handshake. We do not yet know the
+            # trigger, so stating the observation and stopping there beats
+            # sending people to do the one thing already known not to work.
             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.",
+                "FTP SSL error connecting to %s: %s — the printer answered port %s with something "
+                "that is not TLS, so print files, covers and timelapses cannot be fetched from it. "
+                "Pausing FTP to this printer for %.0fs.",
                 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
+            self._abandon_connection()
             return False
         except (OSError, ftplib.Error) as e:
             logger.warning("FTP connection failed to %s: %s (type: %s)", self.ip_address, e, type(e).__name__)
-            self._ftp = None
+            self._abandon_connection()
             return False
 
+    def _abandon_connection(self) -> None:
+        """Drop a connection that never became usable, closing its socket.
+
+        Every failure path in :meth:`connect` used to clear ``self._ftp`` and
+        nothing else, leaving a connected socket for the garbage collector.
+        That is survivable once; it is not survivable at this volume. A single
+        print used to walk ~110 candidate paths, so a printer refusing FTPS
+        got ~110 sockets opened and abandoned in a couple of minutes, and one
+        support bundle recorded 1813 of them in a day (#2780). If the refusal
+        is the printer running out of connection slots -- which fits the
+        evidence better than a wedged service, since a single manual connect
+        to the same printer succeeds -- then abandoning sockets is not just
+        untidy, it is what keeps the printer refusing.
+
+        Uses ``close()`` rather than ``quit()``: QUIT is a command, and there
+        is no working control channel to send it on.
+        """
+        ftp = self._ftp
+        self._ftp = None
+        if ftp is None:
+            return
+        try:
+            ftp.close()
+        except (OSError, ftplib.Error, EOFError):
+            pass  # Best-effort; the socket may already be gone
+
     def disconnect(self):
         """Disconnect from the FTP server."""
         if self._ftp:
             try:
                 self._ftp.quit()
             except (OSError, ftplib.Error, EOFError):
-                pass  # Best-effort FTP cleanup; connection may already be closed
+                # ``quit()`` sends QUIT and only then closes; when the send
+                # raises, ftplib never reaches its own close and the socket
+                # stays open. Close it here rather than leaving it to the GC.
+                self._abandon_connection()
             self._ftp = None
 
     def list_files(self, path: str = "/") -> list[dict]:

+ 41 - 0
backend/app/services/bambu_mqtt.py

@@ -760,7 +760,34 @@ class PrinterState:
     hms_errors: list = field(default_factory=list)  # List of HMSError
     kprofiles: list = field(default_factory=list)  # List of KProfile
     sdcard: bool = False  # SD card inserted
+    # Whether the printer has ever actually told us about `sdcard`. Without this
+    # the default False is indistinguishable from a real "no card", and any
+    # consumer that treats False as evidence would act on silence — which is how
+    # a storage gate turns into a regression for every printer whose firmware
+    # simply doesn't publish the field (#2780).
+    sdcard_reported: bool = False
     store_to_sdcard: bool = False  # Store sent files on SD card (home_flag bit 11)
+    # Scheme+path of a `project_file` dispatch seen on the request topic, from
+    # whoever sent it (the slicer or us). Bambu states where the sliced file
+    # went: `ftp://<name>` is external storage, which FTPS serves, while
+    # `brtc://emmc/<name>` is the printer's internal storage, which it does not.
+    #
+    # Two fields, because the two readers need different guarantees.
+    # ``current_project_url`` belongs to the print now running and is cleared
+    # when that print ends, so a print Bambuddy saw no dispatch for reads as
+    # "unknown" rather than inheriting the previous job's answer. That matters:
+    # 18% of the print starts in #2780's bundle had no dispatch on the request
+    # topic at all (touchscreen reprints, restart recovery), and a stale
+    # internal-storage URL would make those skip an FTPS sweep that could have
+    # found the file — losing an archive that works today.
+    #
+    # ``last_project_url`` is sticky and exists for reporting only: the
+    # connection diagnostic is usually run *after* the print that prompted it,
+    # by which point the per-print value is rightly gone.
+    #
+    # None means we never saw a dispatch — say nothing, don't guess.
+    current_project_url: str | None = None
+    last_project_url: str | None = None
     timelapse: bool = False  # Timelapse recording active
     ipcam: bool = False  # Live view / camera streaming enabled
     wifi_signal: int | None = None  # WiFi signal strength in dBm
@@ -1781,6 +1808,14 @@ class BambuMQTTClient:
             return
         command = print_data.get("command", "")
         if command == "project_file":
+            # Where the dispatcher put the sliced file. Captured for every
+            # project_file, ours included: we publish to this same topic and
+            # subscribe to it, so whoever dispatched last wins, which is exactly
+            # the print the archive lookup is about to go looking for (#2780).
+            url = print_data.get("url")
+            if isinstance(url, str) and url:
+                self.state.current_project_url = url
+                self.state.last_project_url = url
             if "ams_mapping" in print_data:
                 self._captured_ams_mapping = print_data["ams_mapping"]
                 logger.info(
@@ -4526,6 +4561,7 @@ class BambuMQTTClient:
                 self.state.sdcard = "HAS_SDCARD" in raw_sdcard.upper() or raw_sdcard.lower() in ("true", "normal", "1")
             else:
                 self.state.sdcard = bool(raw_sdcard)
+            self.state.sdcard_reported = True
 
         if home_flag is not None:
             store_to_sdcard = bool((home_flag >> 11) & 1)
@@ -5098,6 +5134,11 @@ class BambuMQTTClient:
                 }
             )
             self._captured_ams_mapping = None
+            # Same lifecycle as the mapping above: it described *this* print.
+            # Leaving it set would hand the next print an answer about where a
+            # different file went, and a stale "internal storage" reading costs
+            # an archive that the FTPS sweep would have found (#2780).
+            self.state.current_project_url = None
 
         self._previous_gcode_state = self.state.state
         if current_file:

+ 147 - 0
backend/app/services/print_storage.py

@@ -0,0 +1,147 @@
+"""Can FTPS see the file this print is running from? (#2780)
+
+Bambuddy reads a print's 3MF, cover and timelapse off the printer over implicit
+FTPS on port 990. On every Bambu model that port serves **external storage only**
+-- the SD card or USB stick. It is not a view of the printer's filesystem.
+
+H2-series and P2S firmware default to keeping the sliced file on internal eMMC
+instead, and BambuStudio uploads there over a separate service on port 6000
+(the "BambuTunnelLocal" protocol -- see #2762, which tracks implementing it).
+When that happens there is no file on FTPS to find, at any path, and no TLS
+option, retry or directory guess changes that. The dispatch says so plainly:
+the ``project_file`` command carries ``url``, which is ``ftp://<name>`` for
+external storage and ``brtc://emmc/<name>`` for internal.
+
+Before this module we ignored ``url`` and swept anyway: six filename variants
+across five directories with up to four retries for the 3MF, then sixteen more
+paths for the cover, then the timelapse scan -- roughly 110 FTPS connections per
+print, every one of them certain to 550. The user-visible result was an archive
+card with nothing on it and no stated reason, which read as a Bambuddy bug and
+was reported as one four times (#1170, #2524, #2762, #2780).
+
+The rule here is deliberately one-sided: **skip only on positive evidence**.
+Silence is not evidence -- a printer that never publishes ``sdcard`` and never
+had a ``project_file`` pass through the request topic (some brokers refuse the
+subscription) must keep the old behaviour exactly, or this becomes a regression
+for installs whose archives work fine today.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+# The one URL scheme that means "on external storage, reachable over FTPS".
+# Anything else -- brtc://emmc today, whatever Bambu ships next -- is somewhere
+# port 990 does not serve. Matching the reachable value rather than the
+# unreachable one is what keeps a new scheme from silently reading as fine.
+_EXTERNAL_STORAGE_SCHEME = "ftp"
+
+# Reason slugs. These cross the API into the UI and into the connection
+# diagnostic, so they are part of the contract: the frontend maps each to its
+# own explanation and its own advice. Keep them stable.
+REASON_INTERNAL_STORAGE = "internal_storage"
+REASON_NO_EXTERNAL_STORAGE = "no_external_storage"
+
+
+@dataclass(frozen=True)
+class StorageVerdict:
+    """Whether an FTPS sweep for this print's file is worth running.
+
+    ``reachable`` False always carries a ``reason``; True never does.
+    """
+
+    reachable: bool
+    reason: str | None = None
+
+
+_REACHABLE = StorageVerdict(reachable=True)
+
+
+def url_is_external_storage(project_url: str | None) -> bool | None:
+    """Does *project_url* name a file on external storage?
+
+    ``None`` when there is no URL to read, which is not the same answer as
+    False and must not be collapsed into one by callers.
+    """
+    # Type-checked, not just truth-checked: this value arrives straight off the
+    # wire, so it is whatever the sender put there. Anything that is not a
+    # string is not an answer.
+    if not isinstance(project_url, str) or not project_url:
+        return None
+    scheme, separator, _ = project_url.partition("://")
+    if not separator:
+        # No scheme at all. Real dispatches always carry one, so rather than
+        # guess at a bare path, decline to answer and let the caller fall
+        # through to its existing behaviour.
+        return None
+    return scheme.lower() == _EXTERNAL_STORAGE_SCHEME
+
+
+def external_storage_present(state: object | None) -> bool:
+    """Does the printer have external storage for FTPS to serve at all?
+
+    Narrower than :func:`print_file_reachable_over_ftp` and deliberately so.
+    The printer records its timelapse to the card itself, so *where the sliced
+    file went* says nothing about whether a video exists -- an H2C that kept
+    the 3MF on eMMC still writes ``/timelapse`` to an inserted card. Only the
+    empty-slot case rules a scan out, and only when the printer said the slot
+    is empty rather than never mentioning it.
+    """
+    if state is None:
+        return True
+    return not (getattr(state, "sdcard_reported", False) and not getattr(state, "sdcard", False))
+
+
+def print_file_reachable_over_ftp(state: object | None) -> StorageVerdict:
+    """Decide whether to run an FTPS sweep for the print *state* is running.
+
+    *state* is a ``PrinterState`` (duck-typed so tests and callers can pass a
+    stand-in). Reads ``current_project_url``, ``sdcard`` and ``sdcard_reported``.
+
+    Deliberately the *per-print* URL, not the sticky one: a print Bambuddy saw
+    no dispatch for must read as unknown and sweep, rather than inherit the
+    previous job's destination. Roughly a fifth of the print starts in #2780's
+    bundle had no dispatch on the request topic -- touchscreen reprints and
+    restart recovery -- and inheriting a stale internal-storage answer there
+    would skip a sweep that could have found the file.
+
+    Returns :data:`_REACHABLE` unless something positively says otherwise.
+    """
+    return _verdict(getattr(state, "current_project_url", None), state)
+
+
+def last_print_storage_verdict(state: object | None) -> StorageVerdict:
+    """Same question, asked of the last dispatch seen whenever that was.
+
+    For reporting only -- the connection diagnostic is normally run after the
+    print that prompted it, by which point the per-print URL has been cleared.
+    Never gate an FTPS sweep on this: it may describe a different print.
+    """
+    return _verdict(getattr(state, "last_project_url", None), state)
+
+
+def _verdict(project_url: str | None, state: object | None) -> StorageVerdict:
+    if state is None:
+        return _REACHABLE
+
+    # Strongest signal, and specific to the print in question: the dispatcher
+    # named the destination.
+    external = url_is_external_storage(project_url)
+    if external is False:
+        return StorageVerdict(reachable=False, reason=REASON_INTERNAL_STORAGE)
+    if external is True:
+        # It said external storage, so sweep even if the card flags disagree.
+        # Trusting the specific claim over the general one is what keeps a
+        # printer that misreports `sdcard` from losing archives that work
+        # today -- a false skip is a regression, a needless sweep is only slow.
+        return _REACHABLE
+
+    # Model-independent fallback for printers whose broker refuses the request
+    # topic, so we never see a `project_file` at all. An empty slot means FTPS
+    # has nothing to serve from any path -- but only when the printer actually
+    # said so. `sdcard` defaults to False, and acting on that default would
+    # skip the sweep for every printer that simply doesn't publish the field.
+    if getattr(state, "sdcard_reported", False) and not getattr(state, "sdcard", False):
+        return StorageVerdict(reachable=False, reason=REASON_NO_EXTERNAL_STORAGE)
+
+    return _REACHABLE

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

@@ -21,6 +21,7 @@ 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.print_storage import REASON_INTERNAL_STORAGE, last_print_storage_verdict
 from backend.app.services.printer_manager import printer_manager
 from backend.app.utils.printer_models import has_external_storage, has_remote_storage_toggle
 
@@ -300,11 +301,14 @@ async def run_connection_diagnostic(
     store_to_sdcard = getattr(state, "store_to_sdcard", None) if state else None
     if not model_has_slot or state is None or not state.connected:
         checks.append(DiagnosticCheck(id="external_storage", status="skip"))
-    elif store_to_sdcard is True:
-        checks.append(DiagnosticCheck(id="external_storage", status="pass"))
     elif store_to_sdcard is False and not has_remote_storage_toggle(model):
         # Slot present but no way to enable it on this firmware — don't nag
         # with an unresolvable fail; explain why via the reason param.
+        #
+        # Ahead of the empty-slot check below on purpose (#2524 over #2780):
+        # on a P1-series the toggle cannot be switched on at all, so telling
+        # the operator to insert a card would promise a fix that inserting a
+        # card does not deliver.
         checks.append(
             DiagnosticCheck(
                 id="external_storage",
@@ -312,6 +316,33 @@ async def run_connection_diagnostic(
                 params={"reason": "unsupported_model"},
             )
         )
+    elif getattr(state, "sdcard_reported", False) and not getattr(state, "sdcard", False):
+        # The toggle can be on and still achieve nothing with an empty slot,
+        # and that combination used to report a clean pass — #2780's H2C had
+        # `store_to_sdcard` set and `sdcard` False for three solid weeks while
+        # every one of its archives came out blank. Report the empty slot,
+        # which is the part the operator can actually act on.
+        checks.append(
+            DiagnosticCheck(
+                id="external_storage",
+                status="fail",
+                params={"reason": "no_media"},
+            )
+        )
+    elif not last_print_storage_verdict(state).reachable:
+        # The toggle is on, a card is in, and the printer still put the last
+        # print on internal storage — which is what H2-series and P2S firmware
+        # does, and no setting here changes it (#2762 tracks reading that
+        # storage). A pass here would be a lie; a fail would be unresolvable.
+        checks.append(
+            DiagnosticCheck(
+                id="external_storage",
+                status="warn",
+                params={"reason": REASON_INTERNAL_STORAGE},
+            )
+        )
+    elif store_to_sdcard is True:
+        checks.append(DiagnosticCheck(id="external_storage", status="pass"))
     elif store_to_sdcard is False:
         checks.append(DiagnosticCheck(id="external_storage", status="fail"))
     else:

+ 97 - 5
backend/tests/integration/test_archives_api.py

@@ -731,7 +731,7 @@ class TestNo3MFWarning:
         response = await async_client.get("/api/v1/archives/no-3mf-warning")
 
         assert response.status_code == 200
-        assert response.json() == {"has_fallback": True}
+        assert response.json() == {"has_fallback": True, "reason": None}
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -739,7 +739,7 @@ class TestNo3MFWarning:
         response = await async_client.get("/api/v1/archives/no-3mf-warning")
 
         assert response.status_code == 200
-        assert response.json() == {"has_fallback": False}
+        assert response.json() == {"has_fallback": False, "reason": None}
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -755,7 +755,7 @@ class TestNo3MFWarning:
         response = await async_client.get("/api/v1/archives/no-3mf-warning")
 
         assert response.status_code == 200
-        assert response.json() == {"has_fallback": False}
+        assert response.json() == {"has_fallback": False, "reason": None}
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -775,7 +775,7 @@ class TestNo3MFWarning:
         response = await async_client.get("/api/v1/archives/no-3mf-warning")
 
         assert response.status_code == 200
-        assert response.json() == {"has_fallback": False}
+        assert response.json() == {"has_fallback": False, "reason": None}
         # Sanity: row really is in the DB, we just don't surface it.
         assert (await db_session.get(PrintArchive, archive.id)) is not None
 
@@ -796,7 +796,99 @@ class TestNo3MFWarning:
         assert response.status_code == 200
         # Soft-deleted fallbacks have been actioned (user clearing the
         # evidence). Stop nudging.
-        assert response.json() == {"has_fallback": False}
+        assert response.json() == {"has_fallback": False, "reason": None}
+
+
+class TestNo3MFWarningReason:
+    """Which explanation the banner shows (#2780).
+
+    The endpoint used to return a bare boolean and the banner had one wording:
+    "Store sent files on external storage" is off in the slicer, go turn it on.
+    For H2-series and P2S that is wrong twice over -- the setting is already on
+    and turning it on changes nothing, because the printer keeps the file on
+    internal storage FTPS does not serve. Sending people to re-do a step that
+    cannot help is worse than saying nothing, so the reason travels with the
+    flag.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_internal_storage_is_reported(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            extra_data={"no_3mf_available": True, "no_3mf_reason": "internal_storage"},
+        )
+
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+
+        assert response.json() == {"has_fallback": True, "reason": "internal_storage"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_empty_slot_is_reported(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            extra_data={"no_3mf_available": True, "no_3mf_reason": "no_external_storage"},
+        )
+
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+
+        assert response.json() == {"has_fallback": True, "reason": "no_external_storage"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_known_reason_outranks_archives_that_carry_none(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Every archive written before this field exists carries no reason,
+        and a farm usually has more than one printer. One H2C among four
+        printers should still get the H2C explanation rather than have it
+        drowned out by older rows.
+        """
+        printer = await printer_factory()
+        await archive_factory(printer.id, extra_data={"no_3mf_available": True})
+        await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "internal_storage"})
+        await archive_factory(printer.id, extra_data={"no_3mf_available": True})
+
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+
+        assert response.json() == {"has_fallback": True, "reason": "internal_storage"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_internal_storage_outranks_an_empty_slot(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Both are real, and only one of them is surprising. "Put a card in"
+        is advice the user can act on unprompted; "this model keeps prints
+        somewhere Bambuddy cannot read" is the one they need told.
+        """
+        printer = await printer_factory()
+        await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "no_external_storage"})
+        await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "internal_storage"})
+
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+
+        assert response.json() == {"has_fallback": True, "reason": "internal_storage"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_reason_on_a_non_fallback_archive_is_not_reported(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """The reason only means anything alongside the flag it explains."""
+        printer = await printer_factory()
+        await archive_factory(printer.id, extra_data={"no_3mf_reason": "internal_storage"})
+
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+
+        assert response.json() == {"has_fallback": False, "reason": None}
 
 
 class TestPrintLogEntryDelete:

+ 98 - 1
backend/tests/integration/test_printers_api.py

@@ -4181,7 +4181,20 @@ class TestCoverWhenFileServiceIsWedged:
     @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)
+        # Spell out the storage fields rather than leaving them to MagicMock:
+        # every attribute of a bare mock is truthy, so the storage gate added
+        # in #2780 would read this stub as "the print is on internal storage"
+        # and answer 404 before the handshake check ever ran. This test is
+        # about a printer whose file service stopped answering, so say that
+        # its last print went to the card.
+        state = MagicMock(
+            subtask_name="job",
+            state="RUNNING",
+            gcode_file=None,
+            current_project_url="ftp://job.gcode.3mf",
+            sdcard=True,
+            sdcard_reported=True,
+        )
 
         with (
             patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=state),
@@ -4199,3 +4212,87 @@ class TestCoverWhenFileServiceIsWedged:
         assert "TLS" in response.json()["detail"]
         # The whole point: no FTP fan-out against a printer that cannot answer.
         mock_download.assert_not_called()
+
+
+class TestCoverWhenThePrintIsOnInternalStorage:
+    """The cover lives inside the 3MF, so it is only reachable if the 3MF is.
+
+    #2780: on an H2C or P2S the sliced file routinely stays on internal
+    storage, which FTPS does not serve. Walking all sixteen candidate paths to
+    arrive at a bare 404 spends connections the printer is already short of
+    and tells the user "this print has no cover", which is not what happened.
+    """
+
+    @pytest.fixture(autouse=True)
+    def _clear_404_cache(self):
+        """The route remembers 404s per printer+print so repeat requests skip
+        the fan-out. It is module-level, printer ids restart at 1 for each
+        test, and both tests here use the same subtask name -- so without this
+        the second test reads the first one's cached miss and never reaches
+        the code it is checking.
+        """
+        from backend.app.api.routes.printers import _cover_404_cache
+
+        _cover_404_cache.clear()
+        yield
+        _cover_404_cache.clear()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_skips_the_fan_out_and_says_why(self, async_client: AsyncClient, printer_factory, db_session):
+        printer = await printer_factory(name="H2C")
+        state = MagicMock(
+            subtask_name="job",
+            state="RUNNING",
+            gcode_file=None,
+            current_project_url="brtc://emmc/job.gcode.3mf",
+            sdcard=True,
+            sdcard_reported=True,
+        )
+
+        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=False),
+            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 == 404, response.text
+        assert "internal_storage" in response.json()["detail"]
+        mock_download.assert_not_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_print_on_external_storage_still_fans_out(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """The regression guard. Every install whose covers work today reaches
+        the download exactly as before."""
+        printer = await printer_factory(name="X1C")
+        state = MagicMock(
+            subtask_name="job",
+            state="RUNNING",
+            gcode_file=None,
+            current_project_url="ftp://job.gcode.3mf",
+            sdcard=True,
+            sdcard_reported=True,
+        )
+
+        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=False),
+            patch(
+                "backend.app.api.routes.printers.download_file_try_paths_async",
+                new=AsyncMock(return_value=False),
+            ) as mock_download,
+        ):
+            await async_client.get(f"/api/v1/printers/{printer.id}/cover")
+
+        mock_download.assert_called()

+ 9 - 3
backend/tests/unit/services/test_bambu_ftp.py

@@ -1689,9 +1689,15 @@ class TestHandshakeCoolOff:
         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)
+        # The warning has to say more than the error: the port is open, so
+        # "unblock port 990" is the wrong reading and the operator needs to
+        # know what it actually costs them.
+        assert any("covers and timelapses cannot be fetched" in m for m in messages)
+        # It must NOT prescribe a restart. It used to, and #2780's reporter
+        # power-cycled both affected printers with no effect while a single
+        # manual connection to the same printers completed a clean handshake.
+        # Naming a remedy that is known not to work is worse than naming none.
+        assert not any("restart" in m.lower() for m in messages)
 
     def test_blocked_printer_is_not_contacted_again(self, plaintext_server):
         self._client(plaintext_server).connect()

+ 133 - 0
backend/tests/unit/services/test_ftp_failed_connect_cleanup_2780.py

@@ -0,0 +1,133 @@
+"""A connection that never came up still has to be closed (#2780).
+
+Every failure path in ``BambuFTPClient.connect`` used to clear ``self._ftp``
+and stop there, leaving a connected socket for the garbage collector to
+notice. Once, that is untidy. At the volume this code runs at it is not: a
+single print used to walk ~110 candidate paths, so a printer refusing FTPS got
+~110 sockets opened and dropped in a couple of minutes, and one support bundle
+recorded 1813 in a day.
+
+That matters beyond tidiness, because the leading explanation for the refusal
+is the printer running out of connection slots -- a single manual connect to
+the same printer completes a clean handshake while Bambuddy is failing, and
+vsFTPd answers a per-source limit in cleartext, which is exactly the
+``WRONG_VERSION_NUMBER`` we see. If that is right, abandoning sockets is not a
+side effect of the problem, it is part of what sustains it.
+
+These tests assert the socket is closed, not merely dereferenced, because
+dereferencing is what the old code did and it looked identical from outside.
+"""
+
+import ftplib
+import ssl
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from backend.app.services.bambu_ftp import BambuFTPClient
+
+pytestmark = pytest.mark.unit
+
+
+@pytest.fixture(autouse=True)
+def _no_cooloff():
+    """The SSL path opens a per-IP cool-off that outlives the test."""
+    BambuFTPClient._handshake_blocked_until.clear()
+    yield
+    BambuFTPClient._handshake_blocked_until.clear()
+
+
+def _client(ip="192.168.1.210"):
+    return BambuFTPClient(ip, "12345678", printer_model="P2S")
+
+
+@pytest.mark.parametrize(
+    "error",
+    [
+        # The two the #2780 bundle actually recorded, 1813 and 49 times.
+        ssl.SSLError("[SSL: WRONG_VERSION_NUMBER] wrong version number"),
+        TimeoutError("_ssl.c:1015: The handshake operation timed out"),
+        ftplib.error_perm("530 Login incorrect."),
+        ftplib.error_temp("421 Service not available."),
+        OSError("Connection reset by peer"),
+    ],
+    ids=["ssl", "timeout", "perm", "temp", "oserror"],
+)
+def test_a_failed_connect_closes_its_socket(error):
+    fake_ftp = MagicMock()
+    fake_ftp.connect.side_effect = error
+
+    with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
+        client = _client()
+        assert client.connect() is False
+
+    fake_ftp.close.assert_called_once()
+    # Never QUIT: that is a command, and there is no working control channel
+    # to send it on.
+    fake_ftp.quit.assert_not_called()
+    assert client._ftp is None
+
+
+def test_a_failure_after_connect_still_closes():
+    """Login and prot_p run on a live socket, so a failure there leaks a
+    genuinely established connection -- the worst case for a session limit."""
+    fake_ftp = MagicMock()
+    fake_ftp.login.side_effect = ftplib.error_perm("530 Login incorrect.")
+
+    with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
+        client = _client()
+        assert client.connect() is False
+
+    fake_ftp.close.assert_called_once()
+
+
+def test_close_raising_does_not_propagate():
+    """Cleanup is best-effort; the socket may already be gone. A raise here
+    would turn a handled connect failure into an unhandled one."""
+    fake_ftp = MagicMock()
+    fake_ftp.connect.side_effect = OSError("boom")
+    fake_ftp.close.side_effect = OSError("already closed")
+
+    with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
+        assert _client().connect() is False
+
+
+class TestDisconnect:
+    def test_a_healthy_disconnect_quits(self):
+        fake_ftp = MagicMock()
+
+        with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
+            client = _client()
+            assert client.connect() is True
+        client.disconnect()
+
+        fake_ftp.quit.assert_called_once()
+        assert client._ftp is None
+
+    def test_a_failing_quit_falls_back_to_closing(self):
+        """``ftplib.FTP.quit`` sends QUIT and only then closes, so when the
+        send raises it never reaches its own close and the socket stays open.
+        The old handler swallowed that exception and left it there.
+        """
+        fake_ftp = MagicMock()
+        fake_ftp.quit.side_effect = OSError("Broken pipe")
+
+        with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
+            client = _client()
+            assert client.connect() is True
+        client.disconnect()
+
+        fake_ftp.close.assert_called_once()
+        assert client._ftp is None
+
+
+def test_the_ssl_path_still_opens_the_cooloff():
+    """The cleanup change must not disturb the gate that stops the storm."""
+    fake_ftp = MagicMock()
+    fake_ftp.connect.side_effect = ssl.SSLError("[SSL: WRONG_VERSION_NUMBER] wrong version number")
+
+    with patch("backend.app.services.bambu_ftp.ImplicitFTP_TLS", return_value=fake_ftp):
+        client = _client("192.168.1.211")
+        assert client.connect() is False
+
+    assert BambuFTPClient.handshake_blocked("192.168.1.211") is True

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

@@ -40,11 +40,25 @@ def _port_probe(overrides=None):
     return _probe
 
 
-def _state(*, connected=True, developer_mode=True, store_to_sdcard=True):
+def _state(
+    *,
+    connected=True,
+    developer_mode=True,
+    store_to_sdcard=True,
+    sdcard=True,
+    sdcard_reported=False,
+    last_project_url=None,
+):
+    # sdcard_reported defaults False — "the printer never mentioned its card",
+    # which is what every pre-#2780 test in this file assumes and what keeps
+    # the storage branches out of their way.
     return types.SimpleNamespace(
         connected=connected,
         developer_mode=developer_mode,
         store_to_sdcard=store_to_sdcard,
+        sdcard=sdcard,
+        sdcard_reported=sdcard_reported,
+        last_project_url=last_project_url,
     )
 
 
@@ -459,6 +473,88 @@ class TestExternalStorageCheck:
             result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="P1S"))
         assert _statuses(result)["external_storage"] == "pass"
 
+    # --- #2780: the toggle being on is not the same as it working ---------
+
+    async def test_fails_when_the_toggle_is_on_but_the_slot_is_empty(self):
+        """#2780's H2C reported `store_to_sdcard` set and `sdcard` False for
+        three solid weeks. The check called that a clean pass while every one
+        of its 23 archives came out blank -- the toggle can be on and still
+        achieve nothing with nothing in the slot.
+        """
+        with _Env(state=_state(store_to_sdcard=True, sdcard=False, sdcard_reported=True)):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="H2C"))
+        check = next(c for c in result.checks if c.id == "external_storage")
+        assert check.status == "fail"
+        assert check.params == {"reason": "no_media"}
+        assert result.overall == "problems"
+
+    async def test_warns_when_the_printer_kept_the_print_internally(self):
+        """Toggle on, card in, and the printer still used internal storage --
+        which is what H2-series and P2S firmware does. A pass would be a lie
+        and a fail would be unresolvable, so it warns."""
+        state = _state(
+            store_to_sdcard=True,
+            sdcard=True,
+            sdcard_reported=True,
+            last_project_url="brtc://emmc/Benchy.gcode.3mf",
+        )
+        with _Env(state=state):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="H2C"))
+        check = next(c for c in result.checks if c.id == "external_storage")
+        assert check.status == "warn"
+        assert check.params == {"reason": "internal_storage"}
+        assert result.overall == "warnings"
+
+    async def test_an_external_storage_dispatch_still_passes(self):
+        """The regression guard: an X1C dispatch says `ftp://`, and that must
+        read exactly as it did before any of this existed."""
+        state = _state(
+            store_to_sdcard=True,
+            sdcard=True,
+            sdcard_reported=True,
+            last_project_url="ftp://Benchy.gcode.3mf",
+        )
+        with _Env(state=state):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="X1C"))
+        assert _statuses(result)["external_storage"] == "pass"
+
+    async def test_a_p1_series_keeps_its_non_nagging_skip_with_no_card(self):
+        """#2524 outranks #2780 here. Current P1 firmware exposes no way to
+        turn the option on, so "insert a card" would promise a fix that
+        inserting a card does not deliver -- the unresolvable-fail nagging
+        that #2524 removed, back under a different label.
+        """
+        with _Env(state=_state(store_to_sdcard=False, sdcard=False, sdcard_reported=True)):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="P1S"))
+        check = next(c for c in result.checks if c.id == "external_storage")
+        assert check.status == "skip"
+        assert check.params == {"reason": "unsupported_model"}
+        assert result.overall == "ok"
+
+    async def test_a_model_with_a_reachable_toggle_does_report_the_empty_slot(self):
+        """The counterpart: on an X1C the advice is actionable, so give it."""
+        with _Env(state=_state(store_to_sdcard=False, sdcard=False, sdcard_reported=True)):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="X1C"))
+        check = next(c for c in result.checks if c.id == "external_storage")
+        assert check.status == "fail"
+        assert check.params == {"reason": "no_media"}
+
+    async def test_an_empty_slot_outranks_the_internal_storage_warning(self):
+        """Both apply on a stickless H2C. "Put a card in" is the one the
+        operator can act on, so it must not be masked by the softer warning.
+        """
+        state = _state(
+            store_to_sdcard=True,
+            sdcard=False,
+            sdcard_reported=True,
+            last_project_url="brtc://emmc/Benchy.gcode.3mf",
+        )
+        with _Env(state=state):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="H2C"))
+        check = next(c for c in result.checks if c.id == "external_storage")
+        assert check.status == "fail"
+        assert check.params == {"reason": "no_media"}
+
 
 class TestFtpsTlsProbe:
     """The FTPS probe must reach the handshake, not stop at the TCP accept.

+ 203 - 0
backend/tests/unit/services/test_project_url_capture_2780.py

@@ -0,0 +1,203 @@
+"""Capturing where the printer put the sliced file (#2780).
+
+The ``project_file`` command has always carried a ``url`` saying which storage
+the file landed on -- ``ftp://<name>`` for the card, ``brtc://emmc/<name>`` for
+the printer's internal storage. Bambuddy discarded it and swept FTPS regardless,
+which on an H2C or P2S is ~110 connections that cannot succeed followed by a
+blank archive card with no stated reason.
+
+These tests pin the capture itself. The decision made from it lives in
+:mod:`backend.app.services.print_storage` and is tested separately.
+"""
+
+import pytest
+
+pytestmark = pytest.mark.unit
+
+
+@pytest.fixture
+def mqtt_client():
+    from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+    return BambuMQTTClient(ip_address="192.168.1.210", serial_number="TEST2780", access_code="12345678")
+
+
+class TestProjectUrlCapture:
+    def test_nothing_is_claimed_before_a_dispatch_is_seen(self, mqtt_client):
+        """None is the "we don't know" answer, and consumers depend on it
+        being distinguishable from a known-bad value."""
+        assert mqtt_client.state.current_project_url is None
+
+    def test_an_internal_storage_dispatch_is_recorded(self, mqtt_client):
+        """The shape every H2C and P2S dispatch had in #2780's bundle."""
+        mqtt_client._handle_request_message(
+            {
+                "print": {
+                    "command": "project_file",
+                    "url": "brtc://emmc/169356_204314.STEP.gcode.3mf",
+                    "sequence_id": "20002",
+                }
+            }
+        )
+
+        assert mqtt_client.state.current_project_url == "brtc://emmc/169356_204314.STEP.gcode.3mf"
+
+    def test_our_own_dispatch_is_recorded_too(self, mqtt_client):
+        """We publish to the request topic and subscribe to it, so our own
+        commands come back. Capturing them is wanted, not incidental: after a
+        Bambuddy-launched print the file really is on external storage, and a
+        stale internal-storage URL from the slicer's last job would otherwise
+        suppress a sweep that would have worked.
+
+        ``sequence_id`` 20000 is ours; the external-payload log line keys off
+        that, and this capture must not.
+        """
+        mqtt_client._handle_request_message(
+            {"print": {"command": "project_file", "url": "ftp://Benchy.gcode.3mf", "sequence_id": "20000"}}
+        )
+
+        assert mqtt_client.state.current_project_url == "ftp://Benchy.gcode.3mf"
+
+    def test_the_latest_dispatch_wins(self, mqtt_client):
+        for url in ("brtc://emmc/first.3mf", "ftp://second.3mf"):
+            mqtt_client._handle_request_message({"print": {"command": "project_file", "url": url}})
+
+        assert mqtt_client.state.current_project_url == "ftp://second.3mf"
+
+    def test_a_dispatch_without_a_url_leaves_the_last_one_alone(self, mqtt_client):
+        """Better a slightly stale answer than a wrongly-cleared one: clearing
+        would drop us to "unknown" and re-run the sweep we just learned was
+        pointless."""
+        mqtt_client._handle_request_message({"print": {"command": "project_file", "url": "brtc://emmc/x.3mf"}})
+        mqtt_client._handle_request_message({"print": {"command": "project_file", "ams_mapping": [0]}})
+
+        assert mqtt_client.state.current_project_url == "brtc://emmc/x.3mf"
+
+    @pytest.mark.parametrize("url", [None, "", 12345, [], {}])
+    def test_a_non_string_url_is_ignored(self, mqtt_client, url):
+        """Straight off the wire, so it is whatever the sender sent."""
+        mqtt_client._handle_request_message({"print": {"command": "project_file", "url": url}})
+
+        assert mqtt_client.state.current_project_url is None
+
+    def test_other_commands_do_not_touch_it(self, mqtt_client):
+        mqtt_client._handle_request_message({"print": {"command": "project_file", "url": "ftp://kept.3mf"}})
+        for command in ("pause", "resume", "stop", "gcode_line"):
+            mqtt_client._handle_request_message({"print": {"command": command, "url": "ftp://ignored.3mf"}})
+
+        assert mqtt_client.state.current_project_url == "ftp://kept.3mf"
+
+
+class TestTheTwoFieldsHaveDifferentLifetimes:
+    """``current_project_url`` describes the print now running and is cleared
+    when it ends; ``last_project_url`` is sticky, for reporting after the fact.
+
+    The clearing is the load-bearing half. In #2780's support bundle 14 of 79
+    print starts arrived with no ``project_file`` on the request topic at all
+    -- touchscreen reprints and restart recovery. If those inherited the
+    previous job's destination, one Studio print to internal storage would
+    suppress the FTPS sweep for every screen-started print after it.
+    """
+
+    def test_a_dispatch_sets_both(self, mqtt_client):
+        mqtt_client._handle_request_message({"print": {"command": "project_file", "url": "brtc://emmc/x.3mf"}})
+
+        assert mqtt_client.state.current_project_url == "brtc://emmc/x.3mf"
+        assert mqtt_client.state.last_project_url == "brtc://emmc/x.3mf"
+
+    def test_finishing_a_print_clears_only_the_per_print_one(self, mqtt_client):
+        mqtt_client._handle_request_message({"print": {"command": "project_file", "url": "brtc://emmc/x.3mf"}})
+        # Drive a full RUNNING -> FINISH transition so the real completion
+        # path runs, rather than reaching in and clearing the field by hand.
+        mqtt_client.on_print_complete = lambda data: None
+        mqtt_client._update_state({"gcode_state": "RUNNING"})
+        mqtt_client._update_state({"gcode_state": "FINISH"})
+
+        assert mqtt_client.state.current_project_url is None, (
+            "a print Bambuddy saw no dispatch for must read as unknown, not inherit this one"
+        )
+        assert mqtt_client.state.last_project_url == "brtc://emmc/x.3mf", (
+            "the diagnostic runs after the print that prompted it and still needs the answer"
+        )
+
+    def test_an_aborted_print_clears_it_too(self, mqtt_client):
+        mqtt_client._handle_request_message({"print": {"command": "project_file", "url": "brtc://emmc/x.3mf"}})
+        mqtt_client.on_print_complete = lambda data: None
+        mqtt_client._update_state({"gcode_state": "RUNNING"})
+        mqtt_client._update_state({"gcode_state": "FAILED"})
+
+        assert mqtt_client.state.current_project_url is None
+
+    def test_the_regression_sequence_end_to_end(self, mqtt_client):
+        """Studio print to internal storage, then a print started from the
+        printer's own screen.
+
+        The capture and the gate are pinned separately; this is the sequence
+        that made the split necessary, walked through both at once. The second
+        print's file may well be on the card -- it has to be swept for, not
+        written off on the strength of the first print's destination.
+        """
+        from backend.app.services.print_storage import print_file_reachable_over_ftp
+
+        mqtt_client._update_state({"sdcard": True})
+        mqtt_client.on_print_complete = lambda data: None
+
+        # 1. Studio dispatches to internal storage and the print runs.
+        mqtt_client._handle_request_message({"print": {"command": "project_file", "url": "brtc://emmc/studio.3mf"}})
+        mqtt_client._update_state({"gcode_state": "RUNNING"})
+        assert print_file_reachable_over_ftp(mqtt_client.state).reachable is False
+
+        # 2. It finishes.
+        mqtt_client._update_state({"gcode_state": "FINISH"})
+
+        # 3. The operator reprints from the touchscreen. No project_file
+        #    reaches the request topic at all -- 14 of 79 print starts in the
+        #    reporter's bundle looked exactly like this.
+        mqtt_client._update_state({"gcode_state": "RUNNING"})
+
+        assert print_file_reachable_over_ftp(mqtt_client.state).reachable, (
+            "the screen-started print inherited the Studio print's destination and lost its archive"
+        )
+
+
+class TestSdcardReported:
+    """`sdcard` defaults to False, so "no card" and "never said" look alike.
+
+    Every consumer that treats False as evidence needs the difference, and
+    getting it wrong skips FTP sweeps for printers whose archives work fine.
+    """
+
+    def test_nothing_is_claimed_before_a_status_frame(self, mqtt_client):
+        assert mqtt_client.state.sdcard_reported is False
+        assert mqtt_client.state.sdcard is False
+
+    def test_a_frame_carrying_the_field_marks_it_reported(self, mqtt_client):
+        mqtt_client._update_state({"sdcard": False})
+
+        assert mqtt_client.state.sdcard_reported is True
+        assert mqtt_client.state.sdcard is False
+
+    def test_a_frame_without_the_field_does_not(self, mqtt_client):
+        mqtt_client._update_state({"gcode_state": "RUNNING"})
+
+        assert mqtt_client.state.sdcard_reported is False
+
+    @pytest.mark.parametrize(
+        "raw,expected",
+        [
+            (True, True),
+            (1, True),
+            ("HAS_SDCARD_NORMAL", True),
+            ("normal", True),
+            ("HAS_SDCARD_ABNORMAL", True),
+            (False, False),
+            (0, False),
+            ("", False),
+        ],
+    )
+    def test_the_reported_flag_is_set_for_every_accepted_shape(self, mqtt_client, raw, expected):
+        """Whatever the field means, having seen it is what is recorded."""
+        mqtt_client._update_state({"sdcard": raw})
+
+        assert mqtt_client.state.sdcard_reported is True
+        assert mqtt_client.state.sdcard is expected

+ 219 - 0
backend/tests/unit/test_print_start_skips_unreachable_storage_2780.py

@@ -0,0 +1,219 @@
+"""The 3MF sweep is skipped when it cannot succeed, and the card says why (#2780).
+
+The unit tests around ``print_storage`` pin the decision; this pins that
+``on_print_start`` actually acts on it. Two things have to hold and neither is
+visible from the helper alone:
+
+* No FTP is attempted. The sweep is six filename variants across five
+  directories with up to four retries, and on the reporter's P2S every one of
+  those failed -- 1813 failures in a day against a printer that, on the leading
+  theory, was refusing precisely because of the connection volume.
+* The fallback archive records *which* reason applied. Without it the archives
+  banner falls back to its original wording, which tells the user to switch on
+  a setting that in this case is already on and would not have helped.
+
+The regression case matters as much: a printer whose file service is fine must
+still sweep exactly as before.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.main import (
+    _active_prints,
+    _expected_print_creators,
+    _expected_print_registered_at,
+    _expected_prints,
+    _print_ams_mappings,
+    _timelapse_baselines,
+)
+
+pytestmark = pytest.mark.unit
+
+
+@pytest.fixture(autouse=True)
+def _clear_dicts():
+    for d in (
+        _expected_prints,
+        _expected_print_registered_at,
+        _expected_print_creators,
+        _print_ams_mappings,
+        _active_prints,
+        _timelapse_baselines,
+    ):
+        d.clear()
+    yield
+    for d in (
+        _expected_prints,
+        _expected_print_registered_at,
+        _expected_print_creators,
+        _print_ams_mappings,
+        _active_prints,
+        _timelapse_baselines,
+    ):
+        d.clear()
+
+
+def _printer():
+    printer = MagicMock()
+    printer.id = 1
+    printer.auto_archive = True
+    printer.external_camera_enabled = False
+    printer.external_camera_url = None
+    # Spell this one out: every unset MagicMock attribute is truthy, so leaving
+    # it implicit runs the plate-detection camera grab and each test waits out
+    # its 10s timeout against a printer that does not exist.
+    printer.plate_detection_enabled = False
+    printer.name = "H2C"
+    printer.model = "H2C"
+    printer.ip_address = "192.168.1.210"
+    printer.access_code = "12345678"
+    return printer
+
+
+def _state(current_project_url, sdcard=True, sdcard_reported=True):
+    return MagicMock(
+        current_project_url=current_project_url,
+        sdcard=sdcard,
+        sdcard_reported=sdcard_reported,
+    )
+
+
+async def _run_print_start(state, added):
+    """Drive on_print_start for a print with no matching archive, capturing
+    whatever rows it adds and whether it reached the FTP layer.
+    """
+    printer = _printer()
+
+    def execute_router(stmt, *args, **kwargs):
+        sql = str(stmt).lower()
+        if "from printers" in sql or "from printer " in sql:
+            return MagicMock(
+                scalar_one_or_none=MagicMock(return_value=printer),
+                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[printer]))),
+            )
+        # No existing / expected archive anywhere: the fallback path is the
+        # one under test.
+        return MagicMock(
+            scalar_one_or_none=MagicMock(return_value=None),
+            scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
+        )
+
+    session = AsyncMock()
+    session.__aenter__ = AsyncMock(return_value=session)
+    session.__aexit__ = AsyncMock()
+    session.execute = AsyncMock(side_effect=execute_router)
+    session.commit = AsyncMock()
+    session.refresh = AsyncMock()
+    session.add = MagicMock(side_effect=added.append)
+
+    download = AsyncMock(return_value=False)
+
+    with (
+        patch("backend.app.main.async_session") as session_maker,
+        patch("backend.app.main.notification_service") as notif,
+        patch("backend.app.main.smart_plug_manager") as plug,
+        patch("backend.app.main.ws_manager") as ws,
+        patch("backend.app.main.mqtt_relay") as relay,
+        patch("backend.app.main.printer_manager") as pm,
+        patch("backend.app.main.download_file_async", new=download),
+        patch("backend.app.main.with_ftp_retry", new=AsyncMock(return_value=False)) as retry,
+        patch("backend.app.main.get_cached_3mf", return_value=None),
+        # Imported inside the function, so it has to be patched at its source
+        # or the directory-search fallback opens real sockets to a printer
+        # that is not there and the test hangs on connect timeouts.
+        patch("backend.app.services.bambu_ftp.list_files_async", new=AsyncMock(return_value=[])),
+        patch("backend.app.main.ftps_handshake_blocked", return_value=False),
+        patch("backend.app.main.get_ftp_retry_settings", new=AsyncMock(return_value=(False, 3, 2.0, 30))),
+        patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
+        patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
+        patch("backend.app.main._maybe_start_layer_timelapse"),
+        patch("backend.app.main._capture_timelapse_baseline_at_start", new_callable=AsyncMock),
+    ):
+        session_maker.return_value = session
+        notif.on_print_start = AsyncMock()
+        plug.on_print_start = AsyncMock()
+        ws.send_print_start = AsyncMock()
+        ws.send_archive_updated = AsyncMock()
+        relay.on_print_start = AsyncMock()
+        pm.get_status = MagicMock(return_value=state)
+        pm.get_printer = MagicMock(return_value=MagicMock(serial_number="TEST2780"))
+
+        from backend.app.main import on_print_start
+
+        await on_print_start(
+            1,
+            {"filename": "/data/Metadata/plate_1.gcode", "subtask_name": "Halterung"},
+        )
+
+    return download, retry
+
+
+def _fallback(added):
+    """The archive row the fallback path created, if any."""
+    for row in added:
+        extra = getattr(row, "extra_data", None)
+        if isinstance(extra, dict) and extra.get("no_3mf_available"):
+            return row
+    return None
+
+
+@pytest.mark.asyncio
+async def test_a_print_on_internal_storage_never_touches_ftp():
+    added = []
+
+    download, retry = await _run_print_start(_state("brtc://emmc/Halterung.gcode.3mf"), added)
+
+    download.assert_not_called()
+    retry.assert_not_called()
+
+    archive = _fallback(added)
+    assert archive is not None, "the print still has to be archived, just without slice data"
+    assert archive.extra_data["no_3mf_reason"] == "internal_storage"
+
+
+@pytest.mark.asyncio
+async def test_an_empty_slot_never_touches_ftp():
+    added = []
+
+    download, retry = await _run_print_start(_state(None, sdcard=False, sdcard_reported=True), added)
+
+    download.assert_not_called()
+    retry.assert_not_called()
+    assert _fallback(added).extra_data["no_3mf_reason"] == "no_external_storage"
+
+
+@pytest.mark.asyncio
+async def test_a_print_on_external_storage_still_sweeps():
+    """The regression guard: every install whose archives work today reaches
+    the download exactly as it did before the gate existed."""
+    added = []
+
+    download, _ = await _run_print_start(_state("ftp://Halterung.gcode.3mf"), added)
+
+    download.assert_called()
+
+
+@pytest.mark.asyncio
+async def test_a_printer_that_said_nothing_still_sweeps():
+    """Silence is not evidence. A printer whose broker refuses the request
+    topic never gives us a URL, and its firmware may never publish `sdcard`
+    either -- that install must behave exactly as before."""
+    added = []
+
+    download, _ = await _run_print_start(_state(None, sdcard=False, sdcard_reported=False), added)
+
+    download.assert_called()
+
+
+@pytest.mark.asyncio
+async def test_a_sweep_that_simply_found_nothing_records_no_reason():
+    """The original cause -- the slicer left no file on a card that is present
+    and working -- is still reported with the original wording, because that
+    advice is right for it."""
+    added = []
+
+    await _run_print_start(_state("ftp://Halterung.gcode.3mf"), added)
+
+    assert _fallback(added).extra_data["no_3mf_reason"] is None

+ 257 - 0
backend/tests/unit/test_print_storage_2780.py

@@ -0,0 +1,257 @@
+"""Which prints are worth an FTPS sweep, and which are not (#2780).
+
+The gate this module guards is one-sided on purpose, and both sides matter:
+
+* Missing it costs ~110 doomed FTP connections per print and an archive card
+  that is blank with no stated reason -- the reported bug.
+* Over-applying it costs archives that work today. A printer that never
+  publishes ``sdcard`` and never had a ``project_file`` reach us must sweep
+  exactly as before, or the fix is a regression for everyone else.
+
+So the tests below spend most of their weight on the second failure mode.
+"""
+
+import pytest
+
+from backend.app.services.print_storage import (
+    REASON_INTERNAL_STORAGE,
+    REASON_NO_EXTERNAL_STORAGE,
+    external_storage_present,
+    last_print_storage_verdict,
+    print_file_reachable_over_ftp,
+    url_is_external_storage,
+)
+
+pytestmark = pytest.mark.unit
+
+
+class FakeState:
+    """Stand-in for PrinterState with only the fields the helper reads."""
+
+    def __init__(self, current_project_url=None, sdcard=False, sdcard_reported=False, last_project_url=None):
+        self.current_project_url = current_project_url
+        # Defaults to the per-print value: for every test that does not care
+        # about the distinction, the two readings agree.
+        self.last_project_url = current_project_url if last_project_url is None else last_project_url
+        self.sdcard = sdcard
+        self.sdcard_reported = sdcard_reported
+
+
+class TestUrlScheme:
+    @pytest.mark.parametrize(
+        "url",
+        [
+            "ftp://Benchy.gcode.3mf",
+            # Real dispatches carry names with spaces and non-ASCII; the scheme
+            # is all that is being read and none of that should disturb it.
+            "ftp://Halterung Kühlschrank V2.gcode.3mf",
+            "FTP://Benchy.gcode.3mf",
+        ],
+    )
+    def test_ftp_means_external_storage(self, url):
+        assert url_is_external_storage(url) is True
+
+    @pytest.mark.parametrize(
+        "url",
+        [
+            # The scheme every H2C and P2S dispatch in #2780's bundle carried,
+            # 35 out of 35.
+            "brtc://emmc/169356_204314.STEP.gcode.3mf",
+            "brtc://emmc/Benchy.gcode.3mf",
+        ],
+    )
+    def test_brtc_means_internal_storage(self, url):
+        assert url_is_external_storage(url) is False
+
+    def test_an_unknown_scheme_is_not_assumed_reachable(self):
+        """Matching the reachable value, not the unreachable one.
+
+        If Bambu ships a third scheme, the safe reading is "somewhere we can't
+        see", not "fine" -- an unrecognised scheme that read as reachable would
+        put the storm straight back.
+        """
+        assert url_is_external_storage("sftp://Benchy.3mf") is False
+
+    @pytest.mark.parametrize("url", [None, "", "Benchy.gcode.3mf"])
+    def test_no_usable_url_declines_to_answer(self, url):
+        """None is a third answer and must not collapse into False."""
+        assert url_is_external_storage(url) is None
+
+    @pytest.mark.parametrize("url", [12345, [], {}, object()])
+    def test_a_non_string_url_declines_too(self, url):
+        """The value arrives straight off the wire, so it is whatever the
+        sender put there. Truth-testing alone would let a non-string fall
+        through to the scheme comparison and read as internal storage --
+        which is a silent skip of a sweep that should have run.
+        """
+        assert url_is_external_storage(url) is None
+
+
+class TestSweepIsSkipped:
+    def test_a_print_kept_on_internal_storage(self):
+        verdict = print_file_reachable_over_ftp(FakeState(current_project_url="brtc://emmc/Benchy.gcode.3mf"))
+
+        assert verdict.reachable is False
+        assert verdict.reason == REASON_INTERNAL_STORAGE
+
+    def test_a_printer_that_says_its_slot_is_empty(self):
+        """#2780's H2C: `sdcard` False for three weeks, 800 clean FTPS
+        connections, and a 550 on every single path it asked for."""
+        verdict = print_file_reachable_over_ftp(FakeState(sdcard=False, sdcard_reported=True))
+
+        assert verdict.reachable is False
+        assert verdict.reason == REASON_NO_EXTERNAL_STORAGE
+
+
+class TestSweepStillRuns:
+    """The regression guard. Every case here worked before the gate existed."""
+
+    def test_a_print_on_external_storage(self):
+        assert print_file_reachable_over_ftp(FakeState(current_project_url="ftp://Benchy.gcode.3mf")).reachable
+
+    def test_a_printer_that_never_mentioned_its_card(self):
+        """Silence is not evidence.
+
+        `sdcard` defaults to False, so a printer whose firmware simply never
+        publishes the field looks identical to an empty slot unless the
+        "did it ever say so" flag is honoured. Reading the default as an
+        answer would skip the sweep for every one of them.
+        """
+        assert print_file_reachable_over_ftp(FakeState(sdcard=False, sdcard_reported=False)).reachable
+
+    def test_a_printer_with_a_card_and_no_dispatch_seen(self):
+        """Some brokers refuse the request-topic subscription, so no URL ever
+        arrives. That install must behave exactly as it did before."""
+        assert print_file_reachable_over_ftp(FakeState(sdcard=True, sdcard_reported=True)).reachable
+
+    def test_an_explicit_ftp_url_outranks_a_disagreeing_card_flag(self):
+        """A false skip is a regression; a needless sweep is only slow.
+
+        When the dispatcher says the file went to external storage, believe
+        the specific claim over the general one.
+        """
+        state = FakeState(current_project_url="ftp://Benchy.gcode.3mf", sdcard=False, sdcard_reported=True)
+
+        assert print_file_reachable_over_ftp(state).reachable
+
+    def test_no_state_at_all(self):
+        """Printer not connected, or status not yet populated."""
+        assert print_file_reachable_over_ftp(None).reachable
+
+    def test_a_state_missing_the_fields_entirely(self):
+        """The helper is duck-typed, and a PrinterState from a pickled or
+        partially-constructed source may predate these fields."""
+
+        class Bare:
+            pass
+
+        assert print_file_reachable_over_ftp(Bare()).reachable
+
+
+class TestReasonIsAlwaysPresentWhenUnreachable:
+    @pytest.mark.parametrize(
+        "state",
+        [
+            FakeState(current_project_url="brtc://emmc/x.3mf"),
+            FakeState(sdcard=False, sdcard_reported=True),
+        ],
+    )
+    def test_unreachable_carries_a_reason(self, state):
+        """The reason crosses into the API and picks the banner text. An
+        unreachable verdict without one would render the generic advice --
+        which is the wrong advice, and the whole point of the change."""
+        verdict = print_file_reachable_over_ftp(state)
+
+        assert verdict.reachable is False
+        assert verdict.reason
+
+    def test_reachable_carries_no_reason(self):
+        assert print_file_reachable_over_ftp(FakeState(sdcard=True, sdcard_reported=True)).reason is None
+
+
+class TestTheGateUsesThePerPrintUrlOnly:
+    """A stale URL must never gate a sweep.
+
+    ``current_project_url`` is cleared when a print ends; ``last_project_url``
+    is sticky for reporting. The gate reads only the first, and that is
+    load-bearing rather than tidiness: 18% of the print starts in #2780's
+    support bundle (14 of 79) had no ``project_file`` on the request topic at
+    all -- touchscreen reprints, restart recovery, anything Bambuddy did not
+    see dispatched. If those inherited the previous job's destination, a
+    printer that ran one Studio print to internal storage would skip the FTPS
+    sweep for every subsequent screen-started print, losing archives that work
+    today.
+
+    The asymmetry is what makes it worth pinning: a stale ``ftp://`` costs
+    only a pointless sweep, while a stale ``brtc://`` costs an archive.
+    """
+
+    def test_a_print_with_no_dispatch_of_its_own_still_sweeps(self):
+        """The previous print went to internal storage; this one Bambuddy
+        never saw dispatched. Unknown, so sweep."""
+        state = FakeState(
+            current_project_url=None,
+            last_project_url="brtc://emmc/previous.gcode.3mf",
+            sdcard=True,
+            sdcard_reported=True,
+        )
+
+        assert print_file_reachable_over_ftp(state).reachable
+
+    def test_the_sticky_reading_still_reports_it(self):
+        """The diagnostic is normally run after the print that prompted it, so
+        it needs the answer the gate has rightly forgotten."""
+        state = FakeState(
+            current_project_url=None,
+            last_project_url="brtc://emmc/previous.gcode.3mf",
+            sdcard=True,
+            sdcard_reported=True,
+        )
+
+        verdict = last_print_storage_verdict(state)
+
+        assert verdict.reachable is False
+        assert verdict.reason == REASON_INTERNAL_STORAGE
+
+    def test_an_empty_slot_is_reported_by_both(self):
+        """Not URL-derived, so clearing the per-print value changes nothing."""
+        state = FakeState(sdcard=False, sdcard_reported=True)
+
+        assert print_file_reachable_over_ftp(state).reason == REASON_NO_EXTERNAL_STORAGE
+        assert last_print_storage_verdict(state).reason == REASON_NO_EXTERNAL_STORAGE
+
+    def test_the_sticky_reading_never_gates_a_sweep(self):
+        """Guard against someone swapping the two back: if the gate ever reads
+        the sticky field, the case above starts failing -- and so does this."""
+        import inspect
+
+        from backend.app.services import print_storage
+
+        source = inspect.getsource(print_storage.print_file_reachable_over_ftp)
+        assert "current_project_url" in source
+        assert "last_project_url" not in source.split('"""')[-1]
+
+
+class TestTimelapseUsesTheNarrowerRule:
+    """The printer writes its timelapse to the card itself.
+
+    Where the *sliced file* went says nothing about whether a video exists, so
+    gating the timelapse scan on the URL would silently stop finding videos on
+    every H2C and P2S that has a card in -- a new bug, introduced by the fix
+    for this one.
+    """
+
+    def test_internal_storage_does_not_suppress_the_timelapse_scan(self):
+        state = FakeState(current_project_url="brtc://emmc/x.3mf", sdcard=True, sdcard_reported=True)
+
+        assert print_file_reachable_over_ftp(state).reachable is False
+        assert external_storage_present(state) is True
+
+    def test_an_empty_slot_does_suppress_it(self):
+        assert external_storage_present(FakeState(sdcard=False, sdcard_reported=True)) is False
+
+    def test_silence_does_not(self):
+        assert external_storage_present(FakeState(sdcard=False, sdcard_reported=False)) is True
+
+    def test_no_state_does_not(self):
+        assert external_storage_present(None) is True

+ 103 - 0
frontend/src/__tests__/pages/ArchivesNo3MFBanner.test.tsx

@@ -0,0 +1,103 @@
+/**
+ * The no-3MF banner has to say the right thing (#2780).
+ *
+ * It used to have one wording for every cause: "Store sent files on external
+ * storage" is off in the slicer, go and turn it on. On H2-series and P2S that
+ * is wrong twice over -- the setting is already on, and turning it on again
+ * changes nothing, because the printer keeps the sliced file on internal
+ * storage that FTPS does not serve at all. #2780's reporter followed that
+ * advice, and #1170's before them.
+ *
+ * So these assert the wording actually shown, not just that a banner rendered.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { ArchivesPage } from '../../pages/ArchivesPage';
+import { server } from '../mocks/server';
+
+/** Serve the endpoint under test; everything else can be empty. */
+function mockWarning(body: { has_fallback: boolean; reason: string | null }) {
+  server.use(
+    http.get('/api/v1/archives/no-3mf-warning', () => HttpResponse.json(body)),
+    http.get('/api/v1/archives/', () => HttpResponse.json([])),
+    http.get('/api/v1/archives/stats', () => HttpResponse.json({})),
+    http.get('/api/v1/archives/tags', () => HttpResponse.json([])),
+    http.get('/api/v1/printers/', () => HttpResponse.json([])),
+    http.get('/api/v1/projects/', () => HttpResponse.json([])),
+  );
+}
+
+describe('ArchivesPage no-3MF banner', () => {
+  beforeEach(() => {
+    // The banner is dismissed one-shot via localStorage, and a leftover flag
+    // would make every assertion below pass vacuously.
+    localStorage.clear();
+  });
+
+  it('keeps the slicer-setting wording when the cause is unknown', async () => {
+    mockWarning({ has_fallback: true, reason: null });
+
+    render(<ArchivesPage />);
+
+    expect(
+      await screen.findByText(/couldn't be archived with thumbnails/i),
+    ).toBeInTheDocument();
+    expect(screen.getByText(/Store sent files on external storage/i)).toBeInTheDocument();
+    expect(screen.getByText('See install step 4')).toBeInTheDocument();
+  });
+
+  it('explains internal storage instead of blaming the setting', async () => {
+    mockWarning({ has_fallback: true, reason: 'internal_storage' });
+
+    render(<ArchivesPage />);
+
+    expect(
+      await screen.findByText(/stayed on the printer's internal storage/i),
+    ).toBeInTheDocument();
+    // The specific regression: this reason must NOT send the user to step 4.
+    expect(screen.queryByText('See install step 4')).not.toBeInTheDocument();
+    expect(screen.getByText('Why this happens')).toBeInTheDocument();
+  });
+
+  it('names the empty slot, and offers no link because there is nothing to read', async () => {
+    mockWarning({ has_fallback: true, reason: 'no_external_storage' });
+
+    render(<ArchivesPage />);
+
+    expect(await screen.findByText(/no storage in the printer/i)).toBeInTheDocument();
+    expect(screen.queryByText('See install step 4')).not.toBeInTheDocument();
+    expect(screen.queryByText('Why this happens')).not.toBeInTheDocument();
+  });
+
+  it('shows nothing at all when no print fell back', async () => {
+    mockWarning({ has_fallback: false, reason: null });
+
+    render(<ArchivesPage />);
+
+    await waitFor(() => {
+      expect(screen.queryByText(/couldn't be archived/i)).not.toBeInTheDocument();
+    });
+    expect(screen.queryByText(/internal storage/i)).not.toBeInTheDocument();
+  });
+
+  it('renders a title for every reason rather than an untranslated key', async () => {
+    // The variant suffix is built by string concatenation, so a typo in one
+    // locale key surfaces as a raw "archives.no3mfBanner.titleX" on screen
+    // instead of failing anything.
+    for (const reason of [null, 'internal_storage', 'no_external_storage']) {
+      localStorage.clear();
+      mockWarning({ has_fallback: true, reason });
+
+      const { unmount } = render(<ArchivesPage />);
+
+      // Wait for the banner itself, so a variant that rendered nothing at all
+      // can't satisfy the "no raw key" assertion vacuously.
+      await screen.findByRole('button', { name: 'Dismiss this notice' });
+      expect(screen.queryByText(/archives\.no3mfBanner\./)).not.toBeInTheDocument();
+      unmount();
+    }
+  });
+});

+ 4 - 1
frontend/src/api/client.ts

@@ -4741,7 +4741,10 @@ export const api = {
     return request<Archive[]>(`/archives/search?${params}`);
   },
   rebuildSearchIndex: () => request<{ message: string }>('/archives/search/rebuild-index', { method: 'POST' }),
-  getNo3MFWarning: () => request<{ has_fallback: boolean }>('/archives/no-3mf-warning'),
+  getNo3MFWarning: () =>
+    request<{ has_fallback: boolean; reason: 'internal_storage' | 'no_external_storage' | null }>(
+      '/archives/no-3mf-warning',
+    ),
   updateArchive: (id: number, data: {
     printer_id?: number | null;
     project_id?: number | null;

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

@@ -855,6 +855,11 @@ export default {
       title: 'Einige kürzliche Drucke konnten nicht mit Vorschaubild archiviert werden',
       body: 'Der Slicer hat die .gcode.3mf-Datei nicht auf der SD-Karte des Druckers hinterlegt, daher konnte Bambuddy weder Vorschaubild noch Slicer-Metadaten abrufen. Üblicherweise liegt das daran, dass "Gesendete Dateien auf externem Speicher speichern" im Slicer (Geräte-Tab in Bambu Studio / OrcaSlicer) deaktiviert ist.',
       docsLink: 'Installationsschritt 4 anzeigen',
+      docsLinkInternalStorage: 'Warum das passiert',
+      titleInternalStorage: 'Einige kürzliche Drucke blieben im internen Speicher des Druckers',
+      bodyInternalStorage: 'Der Drucker hat die geslicte Datei in seinem eigenen internen Speicher statt auf der Karte abgelegt, daher gab es für Bambuddy nichts über FTP zu lesen. Die Firmware der H2-Serie und des P2S macht das, und "Gesendete Dateien auf externem Speicher speichern" ändert daran nichts. Diese Drucke werden weiterhin mit Namen und Zeiten archiviert, nur ohne Vorschaubild und Slicer-Metadaten.',
+      titleNoExternalStorage: 'Einige kürzliche Drucke konnten nicht archiviert werden — kein Speicher im Drucker',
+      bodyNoExternalStorage: 'Der Drucker meldet weder Karte noch Stick im Steckplatz, daher hatte die geslicte Datei keinen Ablageort und Bambuddy nichts zu lesen. Legen Sie einen ein, dann wird der nächste Druck vollständig archiviert.',
       dismissLabel: 'Hinweis schließen',
     },
     searchPlaceholder: 'Archiv durchsuchen...',
@@ -6774,6 +6779,8 @@ export default {
         fail: 'Der Drucker meldet, dass diese Option deaktiviert ist. Aktivieren Sie "Gesendete Dateien auf externem Speicher speichern" — bei neuer Firmware (P2S 01.02 / Bambu Studio 2.6+) liegt der Schalter in den Druckeinstellungen des Druckers, bei älteren Versionen im Geräte-Tab von Bambu Studio / OrcaSlicer. Ohne diese Option fehlen jedem archivierten Druck Vorschaubild und Slicer-Metadaten.',
         skip: 'Nicht geprüft — eine aktive MQTT-Verbindung ist erforderlich. Bei älteren Slicern, in denen diese Einstellung nur im Slicer existiert, meldet sie der Drucker nicht — diese Prüfung besteht auch dann, wenn die Option deaktiviert ist. Prüfen Sie Installationsschritt 4 in diesem Fall manuell.',
         skip_unsupported_model: 'Dieses Modell hat einen SD-Slot, aber keine Möglichkeit, die Option zu aktivieren — die aktuelle P1-Firmware zeigt den Schalter in Bambu Studio nicht an und der Drucker hat kein Display. Hier gibt es nichts zu beheben; archivierten Drucken fehlen möglicherweise Vorschaubilder und Slicer-Metadaten, bis Bambu Lab dies per Firmware unterstützt.',
+        fail_no_media: 'Die Option ist aktiviert, aber der Drucker meldet weder Karte noch Stick im Steckplatz, daher können gesendete Dateien nirgends abgelegt werden. Legen Sie einen ein und drucken Sie erneut — bis dahin fehlen jedem archivierten Druck Vorschaubild und Slicer-Metadaten.',
+        warn_internal_storage: 'Die Option ist aktiviert und ein Speicher ist vorhanden, aber dieser Drucker hat den letzten Druck dennoch in seinem internen Speicher behalten, den Bambuddy nicht lesen kann. Die Firmware der H2-Serie und des P2S macht das, und keine Einstellung ändert daran etwas. Drucke werden mit Namen und Zeiten archiviert, aber ohne Vorschaubild und Slicer-Metadaten.',
       },
       port_rtsps: {
         title: 'Kameraport ({{protocol}} {{port}})',

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

@@ -860,6 +860,11 @@ export default {
       body: 'The slicer didn\'t leave the .gcode.3mf on the printer\'s SD card, so Bambuddy couldn\'t pull the thumbnail or slicer metadata. This is usually because "Store sent files on external storage" is off in the slicer (Bambu Studio / OrcaSlicer Device tab).',
       docsLink: 'See install step 4',
       dismissLabel: 'Dismiss this notice',
+      docsLinkInternalStorage: 'Why this happens',
+      titleInternalStorage: 'Some recent prints stayed on the printer\'s internal storage',
+      bodyInternalStorage: 'The printer kept the sliced file on its own internal storage instead of the card, so there was nothing for Bambuddy to read over FTP. H2-series and P2S firmware does this, and "Store sent files on external storage" does not change it. Those prints are still archived with their name and timing, just without a thumbnail or slicer metadata.',
+      titleNoExternalStorage: 'Some recent prints couldn\'t be archived — no storage in the printer',
+      bodyNoExternalStorage: 'The printer reports no card or stick in its slot, so the sliced file had nowhere to land and Bambuddy had nothing to read. Insert one and the next print will archive in full.',
     },
     searchPlaceholder: 'Search archives...',
     filterByPrinter: 'Filter by printer',
@@ -6823,6 +6828,8 @@ export default {
         fail: 'The printer reports this option is off. Enable "Store sent files on external storage" — on newer firmware (P2S 01.02 / Bambu Studio 2.6+) the toggle lives on the printer\'s Print Settings; on older versions it\'s in Bambu Studio / OrcaSlicer\'s Device tab. Without it, every archived print is missing its thumbnail and slicer metadata.',
         skip: 'Not checked — needs a live MQTT connection. On older slicers where this setting lives only in the slicer the printer never reports it, so this check will pass even when the option is off — verify install step 4 manually.',
         skip_unsupported_model: 'This model has an SD slot but no way to turn the option on — current P1-series firmware doesn\'t expose the toggle in Bambu Studio and the printer has no screen. Nothing to fix here; archived prints may lack thumbnails and slicer metadata until Bambu Lab adds firmware support.',
+        fail_no_media: 'The option is on, but the printer reports no card or stick in its slot, so there is nowhere for sent files to go. Insert one and print again — until then every archived print will be missing its thumbnail and slicer metadata.',
+        warn_internal_storage: 'The option is on and storage is present, but this printer still kept the last print on its internal storage, which Bambuddy cannot read. H2-series and P2S firmware does this and no setting changes it. Prints archive with their name and timing, but without a thumbnail or slicer metadata.',
       },
       port_rtsps: {
         title: 'Camera port ({{protocol}} {{port}})',

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

@@ -855,6 +855,11 @@ export default {
       title: 'Algunas impresiones recientes no se pudieron archivar con miniaturas',
       body: 'El slicer no dejó el archivo .gcode.3mf en la tarjeta SD de la impresora, así que Bambuddy no pudo recuperar ni la miniatura ni los metadatos del slicer. Esto suele deberse a que "Almacenar archivos enviados en almacenamiento externo" está desactivado en el slicer (pestaña Dispositivo de Bambu Studio / OrcaSlicer).',
       docsLink: 'Ver paso 4 de instalación',
+      docsLinkInternalStorage: 'Por qué ocurre',
+      titleInternalStorage: 'Algunas impresiones recientes se quedaron en el almacenamiento interno de la impresora',
+      bodyInternalStorage: 'La impresora guardó el archivo laminado en su propio almacenamiento interno en lugar de la tarjeta, así que Bambuddy no tenía nada que leer por FTP. El firmware de la serie H2 y de la P2S hace esto, y «Guardar archivos enviados en almacenamiento externo» no lo cambia. Esas impresiones se siguen archivando con su nombre y sus tiempos, solo que sin miniatura ni metadatos del laminador.',
+      titleNoExternalStorage: 'Algunas impresiones recientes no se pudieron archivar — no hay almacenamiento en la impresora',
+      bodyNoExternalStorage: 'La impresora no detecta ninguna tarjeta ni memoria en su ranura, así que el archivo laminado no tenía dónde aterrizar y Bambuddy nada que leer. Inserte una y la próxima impresión se archivará por completo.',
       dismissLabel: 'Descartar este aviso',
     },
     searchPlaceholder: 'Buscar archivos...',
@@ -6782,6 +6787,8 @@ export default {
         fail: 'La impresora informa que esta opción está desactivada. Active "Almacenar archivos enviados en almacenamiento externo" — en firmware reciente (P2S 01.02 / Bambu Studio 2.6+) el conmutador está en los Ajustes de Impresión de la impresora; en versiones anteriores está en la pestaña Dispositivo de Bambu Studio / OrcaSlicer. Sin esta opción, cada impresión archivada queda sin miniatura ni metadatos del slicer.',
         skip: 'No comprobado — se necesita una conexión MQTT activa. En slicers más antiguos donde este ajuste solo existe en el slicer, la impresora no lo reporta, así que esta comprobación pasa aunque la opción esté desactivada — verifique el paso 4 de la instalación manualmente.',
         skip_unsupported_model: 'Este modelo tiene ranura SD pero no hay forma de activar la opción — el firmware actual de la serie P1 no muestra el interruptor en Bambu Studio y la impresora no tiene pantalla. Aquí no hay nada que arreglar; a las impresiones archivadas pueden faltarles miniaturas y metadatos del slicer hasta que Bambu Lab lo admita por firmware.',
+        fail_no_media: 'La opción está activada, pero la impresora no detecta ninguna tarjeta ni memoria en su ranura, así que los archivos enviados no tienen dónde ir. Inserte una e imprima de nuevo — hasta entonces, cada impresión archivada carecerá de miniatura y de metadatos del laminador.',
+        warn_internal_storage: 'La opción está activada y hay almacenamiento presente, pero esta impresora aun así guardó la última impresión en su almacenamiento interno, que Bambuddy no puede leer. El firmware de la serie H2 y de la P2S hace esto y ningún ajuste lo cambia. Las impresiones se archivan con su nombre y sus tiempos, pero sin miniatura ni metadatos del laminador.',
       },
       port_rtsps: {
         title: 'Puerto de la cámara ({{protocol}} {{port}})',

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

@@ -855,6 +855,11 @@ export default {
       title: 'Certaines impressions récentes n\'ont pas pu être archivées avec leur miniature',
       body: 'Le slicer n\'a pas laissé le fichier .gcode.3mf sur la carte SD de l\'imprimante, donc Bambuddy n\'a pas pu récupérer la miniature ni les métadonnées slicer. C\'est généralement parce que « Stocker les fichiers envoyés sur stockage externe » est désactivé dans le slicer (onglet Périphérique de Bambu Studio / OrcaSlicer).',
       docsLink: 'Voir l\'étape 4 de l\'installation',
+      docsLinkInternalStorage: 'Pourquoi cela arrive',
+      titleInternalStorage: 'Certaines impressions récentes sont restées dans le stockage interne de l\'imprimante',
+      bodyInternalStorage: 'L\'imprimante a conservé le fichier tranché dans son propre stockage interne au lieu de la carte, Bambuddy n\'avait donc rien à lire en FTP. Le firmware des séries H2 et P2S fait cela, et « Stocker les fichiers envoyés sur stockage externe » n\'y change rien. Ces impressions restent archivées avec leur nom et leurs durées, simplement sans miniature ni métadonnées slicer.',
+      titleNoExternalStorage: 'Certaines impressions récentes n\'ont pas pu être archivées — aucun stockage dans l\'imprimante',
+      bodyNoExternalStorage: 'L\'imprimante ne signale ni carte ni clé dans son emplacement, le fichier tranché n\'avait donc nulle part où atterrir et Bambuddy rien à lire. Insérez-en une et la prochaine impression sera archivée complètement.',
       dismissLabel: 'Ignorer ce message',
     },
     searchPlaceholder: 'Chercher dans les archives...',
@@ -6764,6 +6769,8 @@ export default {
         fail: 'L\'imprimante signale que cette option est désactivée. Activez « Stocker les fichiers envoyés sur stockage externe » — sur les firmwares récents (P2S 01.02 / Bambu Studio 2.6+) l\'interrupteur se trouve dans les Paramètres d\'impression de l\'imprimante ; sur les versions antérieures, il est dans l\'onglet Périphérique de Bambu Studio / OrcaSlicer. Sans cette option, chaque impression archivée est dépourvue de miniature et de métadonnées slicer.',
         skip: 'Non vérifié — une connexion MQTT active est requise. Sur les slicers plus anciens où ce paramètre n\'existe que dans le slicer, l\'imprimante ne le signale pas, donc cette vérification passe même si l\'option est désactivée — vérifiez l\'étape 4 de l\'installation manuellement.',
         skip_unsupported_model: 'Ce modèle a un emplacement SD mais aucun moyen d\'activer l\'option — le firmware actuel de la série P1 n\'affiche pas le bouton dans Bambu Studio et l\'imprimante n\'a pas d\'écran. Il n\'y a rien à corriger ici ; les impressions archivées peuvent manquer de miniatures et de métadonnées du slicer jusqu\'à ce que Bambu Lab l\'ajoute par firmware.',
+        fail_no_media: 'L\'option est activée, mais l\'imprimante ne signale ni carte ni clé dans son emplacement : les fichiers envoyés n\'ont nulle part où aller. Insérez-en une et relancez une impression — d\'ici là, chaque impression archivée sera dépourvue de miniature et de métadonnées slicer.',
+        warn_internal_storage: 'L\'option est activée et un stockage est présent, mais cette imprimante a tout de même conservé la dernière impression dans son stockage interne, que Bambuddy ne peut pas lire. Le firmware des séries H2 et P2S fait cela et aucun réglage n\'y change rien. Les impressions sont archivées avec leur nom et leurs durées, mais sans miniature ni métadonnées slicer.',
       },
       port_rtsps: {
         title: 'Port caméra ({{protocol}} {{port}})',

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

@@ -855,6 +855,11 @@ export default {
       title: 'Alcune stampe recenti non sono state archiviate con la miniatura',
       body: 'Lo slicer non ha lasciato il file .gcode.3mf sulla scheda SD della stampante, quindi Bambuddy non ha potuto recuperare né la miniatura né i metadati dello slicer. Solitamente accade perché "Memorizza file inviati su archiviazione esterna" è disattivato nello slicer (scheda Dispositivo di Bambu Studio / OrcaSlicer).',
       docsLink: 'Vedi passo 4 dell\'installazione',
+      docsLinkInternalStorage: 'Perché succede',
+      titleInternalStorage: 'Alcune stampe recenti sono rimaste nella memoria interna della stampante',
+      bodyInternalStorage: 'La stampante ha tenuto il file elaborato nella propria memoria interna anziché sulla scheda, quindi Bambuddy non aveva nulla da leggere via FTP. Il firmware della serie H2 e della P2S si comporta così, e «Salva i file inviati su memoria esterna» non lo cambia. Quelle stampe restano archiviate con nome e tempi, solo senza miniatura né metadati dello slicer.',
+      titleNoExternalStorage: 'Alcune stampe recenti non sono state archiviate — nessuna memoria nella stampante',
+      bodyNoExternalStorage: 'La stampante non rileva né scheda né chiavetta nel suo slot, quindi il file elaborato non aveva dove finire e Bambuddy nulla da leggere. Inseriscine una e la prossima stampa verrà archiviata per intero.',
       dismissLabel: 'Chiudi questo avviso',
     },
     searchPlaceholder: 'Cerca archivi...',
@@ -6763,6 +6768,8 @@ export default {
         fail: 'La stampante segnala che questa opzione è disattivata. Abilita "Memorizza file inviati su archiviazione esterna" — nei firmware recenti (P2S 01.02 / Bambu Studio 2.6+) l\'interruttore si trova nelle Impostazioni di stampa della stampante; nelle versioni precedenti è nella scheda Dispositivo di Bambu Studio / OrcaSlicer. Senza questa opzione, ogni stampa archiviata è priva di miniatura e di metadati dello slicer.',
         skip: 'Non verificato — è necessaria una connessione MQTT attiva. Negli slicer più vecchi dove questa impostazione esiste solo nello slicer, la stampante non la segnala, quindi questo controllo passa anche se l\'opzione è disattivata — verifica manualmente il passo 4 dell\'installazione.',
         skip_unsupported_model: 'Questo modello ha uno slot SD ma nessun modo per attivare l\'opzione — il firmware attuale della serie P1 non mostra l\'interruttore in Bambu Studio e la stampante non ha uno schermo. Non c\'è nulla da correggere qui; alle stampe archiviate potrebbero mancare miniature e metadati dello slicer finché Bambu Lab non aggiungerà il supporto via firmware.',
+        fail_no_media: 'L\'opzione è attiva, ma la stampante non rileva né scheda né chiavetta nel suo slot, quindi i file inviati non hanno dove andare. Inseriscine una e stampa di nuovo — fino ad allora ogni stampa archiviata sarà priva di miniatura e metadati dello slicer.',
+        warn_internal_storage: 'L\'opzione è attiva ed è presente una memoria, ma questa stampante ha comunque tenuto l\'ultima stampa nella propria memoria interna, che Bambuddy non può leggere. Il firmware della serie H2 e della P2S si comporta così e nessuna impostazione lo cambia. Le stampe vengono archiviate con nome e tempi, ma senza miniatura né metadati dello slicer.',
       },
       port_rtsps: {
         title: 'Porta fotocamera ({{protocol}} {{port}})',

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

@@ -854,6 +854,11 @@ export default {
       title: '最近の一部の印刷でサムネイル付きのアーカイブができませんでした',
       body: 'スライサーがプリンターのSDカードに.gcode.3mfファイルを残さなかったため、Bambuddyはサムネイルもスライサーメタデータも取得できませんでした。これは通常、スライサー (Bambu Studio / OrcaSlicer のデバイスタブ) で「送信ファイルを外部ストレージに保存」がオフになっていることが原因です。',
       docsLink: 'インストール手順4を参照',
+      docsLinkInternalStorage: '原因について',
+      titleInternalStorage: '最近の一部の印刷はプリンターの内部ストレージに残りました',
+      bodyInternalStorage: 'プリンターがスライス済みファイルをカードではなく自身の内部ストレージに保存したため、BambuddyがFTPで読み取れるものがありませんでした。H2シリーズとP2Sのファームウェアはこの動作をし、「送信ファイルを外部ストレージに保存」を変更しても変わりません。これらの印刷は名前と時間付きで引き続きアーカイブされますが、サムネイルとスライサーメタデータはありません。',
+      titleNoExternalStorage: '最近の一部の印刷をアーカイブできませんでした — プリンターにストレージがありません',
+      bodyNoExternalStorage: 'プリンターのスロットにカードもUSBメモリも検出されないため、スライス済みファイルの保存先がなく、Bambuddyが読み取るものもありませんでした。挿入すれば次の印刷は完全にアーカイブされます。',
       dismissLabel: 'この通知を閉じる',
     },
     searchPlaceholder: 'アーカイブを検索...',
@@ -6775,6 +6780,8 @@ export default {
         fail: 'プリンターはこのオプションが無効と報告しています。「送信ファイルを外部ストレージに保存」を有効にしてください — 新しいファームウェア (P2S 01.02 / Bambu Studio 2.6以降) ではプリンター本体の印刷設定にトグルがあります。古いバージョンでは Bambu Studio / OrcaSlicer のデバイスタブにあります。この設定がないと、アーカイブされた印刷にはサムネイルもスライサーメタデータも残りません。',
         skip: '未確認 — アクティブなMQTT接続が必要です。古いスライサーでこの設定がスライサー側のみに存在する場合、プリンターはそれを報告しないため、オプションが無効でもこのチェックは通過します — インストール手順4を手動で確認してください。',
         skip_unsupported_model: 'このモデルにはSDスロットがありますが、オプションを有効にする方法がありません — 現在のP1シリーズのファームウェアはBambu Studioにトグルを表示せず、プリンターに画面もありません。ここで修正すべきことはありません。Bambu Labがファームウェアで対応するまで、アーカイブされた印刷にはサムネイルやスライサーのメタデータが欠ける場合があります。',
+        fail_no_media: 'オプションは有効ですが、プリンターのスロットにカードもUSBメモリも検出されないため、送信ファイルの保存先がありません。挿入して再度印刷してください。それまでアーカイブされる印刷にはサムネイルとスライサーメタデータがありません。',
+        warn_internal_storage: 'オプションは有効でストレージも装着されていますが、このプリンターは直近の印刷をBambuddyが読み取れない内部ストレージに保存しました。H2シリーズとP2Sのファームウェアはこの動作をし、どの設定でも変わりません。印刷は名前と時間付きでアーカイブされますが、サムネイルとスライサーメタデータはありません。',
       },
       port_rtsps: {
         title: 'カメラポート ({{protocol}} {{port}})',

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

@@ -811,6 +811,11 @@ export default {
       title: '최근 일부 인쇄가 썸네일과 함께 아카이브되지 않았습니다',
       body: '슬라이서가 프린터의 SD 카드에 .gcode.3mf 파일을 남기지 않아서 Bambuddy가 썸네일이나 슬라이서 메타데이터를 가져올 수 없었습니다. 일반적으로 슬라이서 (Bambu Studio / OrcaSlicer 장치 탭)에서 "전송된 파일을 외부 저장소에 저장"이 꺼져 있을 때 발생합니다.',
       docsLink: '설치 단계 4 보기',
+      docsLinkInternalStorage: '왜 이런 일이 생기나요',
+      titleInternalStorage: '최근 일부 출력물이 프린터 내부 저장소에 남았습니다',
+      bodyInternalStorage: '프린터가 슬라이싱된 파일을 카드가 아니라 자체 내부 저장소에 보관해서 Bambuddy가 FTP로 읽을 것이 없었습니다. H2 시리즈와 P2S 펌웨어가 이렇게 동작하며 «보낸 파일을 외부 저장소에 저장»으로는 바뀌지 않습니다. 해당 출력물은 이름과 시간과 함께 계속 보관되지만 썸네일과 슬라이서 메타데이터는 없습니다.',
+      titleNoExternalStorage: '최근 일부 출력물을 보관하지 못했습니다 — 프린터에 저장소가 없습니다',
+      bodyNoExternalStorage: '프린터 슬롯에 카드도 USB도 감지되지 않아 슬라이싱된 파일이 저장될 곳이 없었고 Bambuddy가 읽을 것도 없었습니다. 하나 넣으면 다음 출력물은 온전히 보관됩니다.',
       dismissLabel: '이 알림 닫기'
     },
     searchPlaceholder: '아카이브 검색...',
@@ -6871,7 +6876,9 @@ export default {
         pass: '프린터가 이 옵션이 켜져 있다고 보고합니다 — 전송된 파일이 SD 카드에 저장되며 아카이브에 썸네일과 슬라이서 메타데이터가 포함됩니다.',
         fail: '프린터가 이 옵션이 꺼져 있다고 보고합니다. "전송된 파일을 외부 저장소에 저장"을 활성화하세요 — 최신 펌웨어 (P2S 01.02 / Bambu Studio 2.6 이상)에서는 프린터의 인쇄 설정에 토글이 있고, 이전 버전에서는 Bambu Studio / OrcaSlicer의 장치 탭에 있습니다. 이 옵션이 없으면 아카이브된 모든 인쇄물에 썸네일과 슬라이서 메타데이터가 없습니다.',
         skip: '확인되지 않음 — 활성 MQTT 연결이 필요합니다. 이 설정이 슬라이서에만 존재하는 이전 슬라이서에서는 프린터가 보고하지 않으므로, 옵션이 꺼져 있어도 이 검사는 통과합니다 — 설치 단계 4를 수동으로 확인하세요.',
-        skip_unsupported_model: '이 모델에는 SD 슬롯이 있지만 옵션을 켤 방법이 없습니다 — 현재 P1 시리즈 펌웨어는 Bambu Studio에 토글을 표시하지 않으며 프린터에 화면도 없습니다. 여기서 고칠 것은 없습니다. Bambu Lab이 펌웨어로 지원할 때까지 보관된 출력물에는 썸네일과 슬라이서 메타데이터가 없을 수 있습니다.'
+        skip_unsupported_model: '이 모델에는 SD 슬롯이 있지만 옵션을 켤 방법이 없습니다 — 현재 P1 시리즈 펌웨어는 Bambu Studio에 토글을 표시하지 않으며 프린터에 화면도 없습니다. 여기서 고칠 것은 없습니다. Bambu Lab이 펌웨어로 지원할 때까지 보관된 출력물에는 썸네일과 슬라이서 메타데이터가 없을 수 있습니다.',
+        fail_no_media: '옵션은 켜져 있지만 프린터 슬롯에 카드도 USB도 감지되지 않아 보낸 파일이 갈 곳이 없습니다. 하나 넣고 다시 출력하세요. 그전까지는 보관되는 모든 출력물에 썸네일과 슬라이서 메타데이터가 없습니다.',
+        warn_internal_storage: '옵션이 켜져 있고 저장소도 있지만 이 프린터는 마지막 출력물을 Bambuddy가 읽을 수 없는 내부 저장소에 보관했습니다. H2 시리즈와 P2S 펌웨어가 이렇게 동작하며 어떤 설정으로도 바뀌지 않습니다. 출력물은 이름과 시간과 함께 보관되지만 썸네일과 슬라이서 메타데이터는 없습니다.',
       },
       port_rtsps: {
         title: '카메라 포트 ({{protocol}} {{port}})',

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

@@ -855,6 +855,11 @@ export default {
       title: 'Algumas impressões recentes não puderam ser arquivadas com miniaturas',
       body: 'O fatiador não deixou o arquivo .gcode.3mf no cartão SD da impressora, então o Bambuddy não conseguiu obter nem a miniatura nem os metadados do fatiador. Normalmente isso acontece porque "Armazenar arquivos enviados no armazenamento externo" está desligado no fatiador (aba Dispositivo do Bambu Studio / OrcaSlicer).',
       docsLink: 'Ver passo 4 da instalação',
+      docsLinkInternalStorage: 'Por que isso acontece',
+      titleInternalStorage: 'Algumas impressões recentes ficaram no armazenamento interno da impressora',
+      bodyInternalStorage: 'A impressora manteve o arquivo fatiado no próprio armazenamento interno em vez do cartão, então o Bambuddy não tinha nada para ler via FTP. O firmware da série H2 e da P2S faz isso, e «Armazenar arquivos enviados em armazenamento externo» não muda nada. Essas impressões continuam arquivadas com nome e tempos, apenas sem miniatura nem metadados do fatiador.',
+      titleNoExternalStorage: 'Algumas impressões recentes não puderam ser arquivadas — sem armazenamento na impressora',
+      bodyNoExternalStorage: 'A impressora não detecta cartão nem pendrive no slot, então o arquivo fatiado não tinha onde ficar e o Bambuddy nada para ler. Insira um e a próxima impressão será arquivada por completo.',
       dismissLabel: 'Dispensar este aviso',
     },
     searchPlaceholder: 'Pesquisar arquivos...',
@@ -6763,6 +6768,8 @@ export default {
         fail: 'A impressora informa que esta opção está desligada. Ative "Armazenar arquivos enviados no armazenamento externo" — em firmwares recentes (P2S 01.02 / Bambu Studio 2.6+) o interruptor fica nas Configurações de Impressão da impressora; em versões mais antigas, está na aba Dispositivo do Bambu Studio / OrcaSlicer. Sem essa opção, cada impressão arquivada fica sem miniatura nem metadados do fatiador.',
         skip: 'Não verificado — é necessária uma conexão MQTT ativa. Em fatiadores mais antigos onde essa configuração existe apenas no fatiador, a impressora não a reporta, então esta verificação passa mesmo com a opção desligada — verifique o passo 4 da instalação manualmente.',
         skip_unsupported_model: 'Este modelo tem slot SD mas nenhuma forma de ativar a opção — o firmware atual da série P1 não mostra o botão no Bambu Studio e a impressora não tem tela. Não há nada a corrigir aqui; as impressões arquivadas podem ficar sem miniaturas e metadados do fatiador até que a Bambu Lab adicione suporte por firmware.',
+        fail_no_media: 'A opção está ligada, mas a impressora não detecta cartão nem pendrive no slot, então os arquivos enviados não têm para onde ir. Insira um e imprima novamente — até lá, toda impressão arquivada ficará sem miniatura e sem metadados do fatiador.',
+        warn_internal_storage: 'A opção está ligada e há armazenamento presente, mas esta impressora ainda assim manteve a última impressão no armazenamento interno, que o Bambuddy não consegue ler. O firmware da série H2 e da P2S faz isso e nenhuma configuração muda. As impressões são arquivadas com nome e tempos, mas sem miniatura nem metadados do fatiador.',
       },
       port_rtsps: {
         title: 'Porta da câmera ({{protocol}} {{port}})',

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

@@ -810,6 +810,11 @@ export default {
       title: "Некоторые последние задания сохранены без миниатюр",
       body: "Слайсер не оставил файл .gcode.3mf на SD-карте принтера, поэтому Bambuddy не смог получить миниатюру и метаданные слайсера. Обычно это происходит, если в слайсере отключена настройка «Сохранять отправленные файлы во внешнем хранилище» (вкладка «Устройство» в Bambu Studio / OrcaSlicer).",
       docsLink: "См. шаг 4 установки",
+      docsLinkInternalStorage: 'Почему так происходит',
+      titleInternalStorage: 'Некоторые недавние печати остались во внутренней памяти принтера',
+      bodyInternalStorage: 'Принтер сохранил нарезанный файл в собственной внутренней памяти, а не на карте, поэтому Bambuddy было нечего читать по FTP. Прошивка серии H2 и P2S так и делает, и «Сохранять отправленные файлы на внешнем накопителе» этого не меняет. Такие печати всё равно архивируются с названием и временем, только без миниатюры и метаданных слайсера.',
+      titleNoExternalStorage: 'Некоторые недавние печати не удалось архивировать — в принтере нет накопителя',
+      bodyNoExternalStorage: 'Принтер не видит ни карты, ни флешки в слоте, поэтому нарезанному файлу некуда было попасть, а Bambuddy нечего читать. Вставьте накопитель, и следующая печать будет заархивирована полностью.',
       dismissLabel: "Закрыть это уведомление",
     },
     searchPlaceholder: "Поиск в архиве...",
@@ -6403,6 +6408,8 @@ export default {
         fail: "Принтер сообщает, что параметр выключен. Включите «Сохранять отправленные файлы во внешнем хранилище»: в новых прошивках (P2S 01.02 / Bambu Studio 2.6+) переключатель находится в настройках печати принтера, в старых — на вкладке «Устройство» Bambu Studio / OrcaSlicer. Без этого у всех архивных заданий не будет миниатюр и метаданных слайсера.",
         skip: "Не проверено — требуется активное подключение MQTT. В старых версиях слайсеров этот параметр существует только в слайсере и принтер его не сообщает, поэтому проверка может пройти даже при выключенном параметре. Проверьте шаг 4 установки вручную.",
         skip_unsupported_model: "В этой модели есть слот SD-карты, но включить соответствующую функцию невозможно: текущая прошивка серии P1 не предоставляет переключатель в Bambu Studio, а у принтера нет экрана. Исправлять здесь нечего. Пока Bambu Lab не добавит поддержку в прошивку, архивные задания могут сохраняться без миниатюр и метаданных слайсера.",
+        fail_no_media: 'Параметр включён, но принтер не видит ни карты, ни флешки в слоте, поэтому отправленным файлам некуда деваться. Вставьте накопитель и напечатайте снова — до тех пор у каждой архивной печати не будет ни миниатюры, ни метаданных слайсера.',
+        warn_internal_storage: 'Параметр включён и накопитель на месте, но этот принтер всё равно оставил последнюю печать во внутренней памяти, которую Bambuddy не может прочитать. Прошивка серии H2 и P2S так и делает, и никакая настройка этого не меняет. Печати архивируются с названием и временем, но без миниатюры и метаданных слайсера.',
       },
       port_rtsps: {
         title: "Порт камеры ({{protocol}} {{port}})",

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

@@ -855,6 +855,11 @@ export default {
       title: 'Bazı son baskılar küçük resimlerle birlikte arşivlenemedi',
       body: 'Dilimleyici yazıcının SD kartına .gcode.3mf dosyasını bırakmadı, bu nedenle Bambuddy küçük resmi veya dilimleyici meta verilerini alamadı. Bu genellikle dilimleyicide (Bambu Studio / OrcaSlicer\'in Cihaz sekmesi) "Gönderilen dosyaları harici depolamada sakla" seçeneğinin kapalı olmasından kaynaklanır.',
       docsLink: 'Kurulum adımı 4\'ü görüntüle',
+      docsLinkInternalStorage: 'Bu neden oluyor',
+      titleInternalStorage: 'Bazı son baskılar yazıcının dahili depolamasında kaldı',
+      bodyInternalStorage: 'Yazıcı dilimlenmiş dosyayı karta değil kendi dahili depolamasına yazdı, bu yüzden Bambuddy\'nin FTP üzerinden okuyacağı bir şey yoktu. H2 serisi ve P2S donanım yazılımı bunu yapar ve «Gönderilen dosyaları harici depolamada sakla» bunu değiştirmez. Bu baskılar adları ve süreleriyle yine arşivlenir, yalnızca küçük resim ve dilimleyici meta verileri olmadan.',
+      titleNoExternalStorage: 'Bazı son baskılar arşivlenemedi — yazıcıda depolama yok',
+      bodyNoExternalStorage: 'Yazıcı yuvasında kart veya bellek bildirmiyor, bu yüzden dilimlenmiş dosyanın ineceği bir yer ve Bambuddy\'nin okuyacağı bir şey yoktu. Bir tane takın, sonraki baskı eksiksiz arşivlenecek.',
       dismissLabel: 'Bu bildirimi kapat',
     },
     searchPlaceholder: 'Arşivlerde ara...',
@@ -6713,6 +6718,8 @@ export default {
         fail: 'Yazıcı bu seçeneğin kapalı olduğunu bildiriyor. "Gönderilen dosyaları harici depolamada sakla" seçeneğini etkinleştirin — yeni donanım yazılımlarında (P2S 01.02 / Bambu Studio 2.6+) düğme yazıcının Baskı Ayarları\'nda; eski sürümlerde Bambu Studio / OrcaSlicer\'in Cihaz sekmesindedir. Bu seçenek olmadan, arşivlenen her baskıda küçük resim ve dilimleyici meta verisi olmayacak.',
         skip: 'Kontrol edilmedi — etkin bir MQTT bağlantısı gerekli. Bu ayarın yalnızca dilimleyicide bulunduğu eski dilimleyicilerde yazıcı bunu bildirmez, bu nedenle seçenek kapalı olsa bile bu kontrol geçer — kurulum adımı 4\'ü manuel olarak doğrulayın.',
         skip_unsupported_model: 'Bu modelde SD yuvası var ancak seçeneği açmanın bir yolu yok — mevcut P1 serisi bellenim, Bambu Studio\'da bu anahtarı göstermiyor ve yazıcının ekranı yok. Burada düzeltilecek bir şey yok; Bambu Lab bellenim desteği ekleyene kadar arşivlenen baskılarda küçük resimler ve dilimleyici meta verileri eksik olabilir.',
+        fail_no_media: 'Seçenek açık, ancak yazıcı yuvasında kart veya bellek bildirmiyor, dolayısıyla gönderilen dosyaların gideceği bir yer yok. Bir tane takıp yeniden yazdırın — o zamana kadar arşivlenen her baskıda küçük resim ve dilimleyici meta verileri eksik olacak.',
+        warn_internal_storage: 'Seçenek açık ve depolama takılı, ancak bu yazıcı yine de son baskıyı Bambuddy\'nin okuyamadığı dahili depolamasında tuttu. H2 serisi ve P2S donanım yazılımı bunu yapar ve hiçbir ayar bunu değiştirmez. Baskılar adları ve süreleriyle arşivlenir, ancak küçük resim ve dilimleyici meta verileri olmadan.',
       },
       port_rtsps: {
         title: 'Kamera portu ({{protocol}} {{port}})',

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

@@ -859,6 +859,11 @@ export default {
       title: "Деякі нещодавні роздруківки не вдалося заархівувати з мініатюрами",
       body: "Слайсер не залишив файл .gcode.3mf на SD-картці принтера, тому Bambuddy не зміг отримати мініатюру або метадані слайсера. Зазвичай це стається, коли в слайсері вимкнено параметр «Зберігати надіслані файли на зовнішньому накопичувачі» (вкладка «Пристрій» у Bambu Studio або OrcaSlicer).",
       docsLink: "Переглянути крок 4 встановлення",
+      docsLinkInternalStorage: 'Чому так стається',
+      titleInternalStorage: 'Деякі нещодавні друки залишилися у внутрішній пам\'яті принтера',
+      bodyInternalStorage: 'Принтер зберіг нарізаний файл у власній внутрішній пам\'яті, а не на картці, тож Bambuddy не мав чого читати через FTP. Прошивка серії H2 та P2S робить саме так, і «Зберігати надіслані файли на зовнішньому носії» цього не змінює. Такі друки все одно архівуються з назвою та часом, лише без мініатюри та метаданих слайсера.',
+      titleNoExternalStorage: 'Деякі нещодавні друки не вдалося заархівувати — у принтері немає носія',
+      bodyNoExternalStorage: 'Принтер не бачить ані картки, ані флешки у слоті, тож нарізаному файлу не було куди потрапити, а Bambuddy — що читати. Вставте носій, і наступний друк заархівується повністю.',
       dismissLabel: "Відхилити це повідомлення",
     },
     searchPlaceholder: "Пошук в архівах...",
@@ -6817,6 +6822,8 @@ export default {
         fail: "Принтер повідомляє, що цей параметр вимкнено. Увімкніть «Зберігати надіслані файли в зовнішньому сховищі». У новіших версіях прошивки (P2S 01.02 / Bambu Studio 2.6+) перемикач розташований у налаштуваннях друку на принтері; у старіших — на вкладці «Пристрій» у Bambu Studio або OrcaSlicer. Без цього параметра в кожному архівованому друці будуть відсутні мініатюра та метадані слайсера.",
         skip: "Не перевірено — потрібне активне MQTT-з’єднання. У старіших слайсерах, де цей параметр існує лише в самому слайсері, принтер його не повідомляє. Тому перевірка може бути успішною навіть за вимкненого параметра — перевірте крок установлення 4 вручну.",
         skip_unsupported_model: "Ця модель має слот для SD-картки, але не дає змоги ввімкнути цей параметр: поточна прошивка принтерів серії P1 не показує перемикач у Bambu Studio, а сам принтер не має екрана. Виправляти нічого не потрібно; доки Bambu Lab не додасть підтримку в прошивці, в архівованих друках можуть бути відсутні мініатюри та метадані слайсера.",
+        fail_no_media: 'Параметр увімкнено, але принтер не бачить ані картки, ані флешки у слоті, тож надісланим файлам немає куди подітися. Вставте носій і надрукуйте ще раз — доти кожен заархівований друк буде без мініатюри та метаданих слайсера.',
+        warn_internal_storage: 'Параметр увімкнено і носій на місці, але цей принтер усе одно залишив останній друк у внутрішній пам\'яті, яку Bambuddy не може прочитати. Прошивка серії H2 та P2S робить саме так, і жодне налаштування цього не змінює. Друки архівуються з назвою та часом, але без мініатюри та метаданих слайсера.',
       },
       port_rtsps: {
         title: "Порт камери ({{protocol}} {{port}})",

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

@@ -855,6 +855,11 @@ export default {
       title: '最近的一些打印未能附带缩略图归档',
       body: '切片机没有在打印机的 SD 卡上留下 .gcode.3mf 文件,因此 Bambuddy 无法获取缩略图或切片机元数据。这通常是因为切片机 (Bambu Studio / OrcaSlicer 的设备选项卡) 中"将发送的文件存储在外部存储中"已关闭。',
       docsLink: '查看安装步骤 4',
+      docsLinkInternalStorage: '为什么会这样',
+      titleInternalStorage: '最近有些打印留在了打印机的内部存储中',
+      bodyInternalStorage: '打印机把切片文件保存在自己的内部存储而不是存储卡上,因此 Bambuddy 通过 FTP 读不到任何东西。H2 系列和 P2S 固件就是这样,「将发送的文件存储到外部存储」也改变不了。这些打印仍会带着名称和时间归档,只是没有缩略图和切片元数据。',
+      titleNoExternalStorage: '最近有些打印无法归档 — 打印机中没有存储介质',
+      bodyNoExternalStorage: '打印机的插槽中未检测到存储卡或U盘,切片文件无处存放,Bambuddy 也无从读取。插入一个,下次打印就会完整归档。',
       dismissLabel: '关闭此通知',
     },
     searchPlaceholder: '搜索归档...',
@@ -6762,6 +6767,8 @@ export default {
         fail: '打印机报告此选项已关闭。请启用"将发送的文件存储在外部存储中" — 在较新固件 (P2S 01.02 / Bambu Studio 2.6+) 中,开关位于打印机的打印设置中;在较旧版本中位于 Bambu Studio / OrcaSlicer 的设备选项卡中。如果不启用此选项,每次归档的打印都将没有缩略图也没有切片机元数据。',
         skip: '未检查 — 需要有效的 MQTT 连接。在该设置仅存在于切片机中的较旧切片机上,打印机不会报告此设置,因此即使选项已关闭,此检查也会通过 — 请手动验证安装步骤 4。',
         skip_unsupported_model: '此型号有 SD 卡槽,但无法开启该选项 — 当前 P1 系列固件不会在 Bambu Studio 中显示此开关,且打印机没有屏幕。这里无需修复;在 Bambu Lab 通过固件添加支持之前,存档的打印可能缺少缩略图和切片元数据。',
+        fail_no_media: '该选项已开启,但打印机的插槽中未检测到存储卡或U盘,发送的文件无处存放。插入一个再打印一次 — 在此之前,每一次归档的打印都会缺少缩略图和切片元数据。',
+        warn_internal_storage: '该选项已开启且存储介质在位,但这台打印机仍把上一次打印留在了 Bambuddy 无法读取的内部存储中。H2 系列和 P2S 固件就是这样,任何设置都改变不了。打印会带着名称和时间归档,但没有缩略图和切片元数据。',
       },
       port_rtsps: {
         title: '摄像头端口({{protocol}} {{port}})',

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

@@ -855,6 +855,11 @@ export default {
       title: '最近的一些列印未能附帶縮圖封存',
       body: '切片機沒有在印表機的 SD 卡上留下 .gcode.3mf 檔案,因此 Bambuddy 無法取得縮圖或切片機中繼資料。這通常是因為切片機 (Bambu Studio / OrcaSlicer 的裝置分頁) 中「將傳送的檔案儲存在外部儲存中」已關閉。',
       docsLink: '檢視安裝步驟 4',
+      docsLinkInternalStorage: '為什麼會這樣',
+      titleInternalStorage: '最近有些列印留在了印表機的內部儲存中',
+      bodyInternalStorage: '印表機把切片檔案儲存在自己的內部儲存而不是記憶卡上,因此 Bambuddy 透過 FTP 讀不到任何東西。H2 系列與 P2S 韌體就是這樣,「將傳送的檔案儲存到外部儲存」也改變不了。這些列印仍會帶著名稱與時間歸檔,只是沒有縮圖與切片中繼資料。',
+      titleNoExternalStorage: '最近有些列印無法歸檔 — 印表機中沒有儲存媒體',
+      bodyNoExternalStorage: '印表機的插槽中未偵測到記憶卡或隨身碟,切片檔案無處存放,Bambuddy 也無從讀取。插入一個,下次列印就會完整歸檔。',
       dismissLabel: '關閉此通知',
     },
     searchPlaceholder: '搜尋歸檔...',
@@ -6762,6 +6767,8 @@ export default {
         fail: '印表機回報此選項已關閉。請啟用「將傳送的檔案儲存在外部儲存中」 — 在較新韌體 (P2S 01.02 / Bambu Studio 2.6+) 中,開關位於印表機的列印設定中;在較舊版本中位於 Bambu Studio / OrcaSlicer 的裝置分頁中。如果未啟用此選項,每次封存的列印都將沒有縮圖也沒有切片機中繼資料。',
         skip: '未檢查 — 需要有效的 MQTT 連線。在該設定僅存在於切片機中的較舊切片機上,印表機不會回報此設定,因此即使選項已關閉,此檢查也會通過 — 請手動驗證安裝步驟 4。',
         skip_unsupported_model: '此型號有 SD 卡槽,但無法開啟該選項 — 目前 P1 系列韌體不會在 Bambu Studio 中顯示此開關,且印表機沒有螢幕。這裡無需修復;在 Bambu Lab 透過韌體加入支援之前,封存的列印可能缺少縮圖和切片中繼資料。',
+        fail_no_media: '該選項已開啟,但印表機的插槽中未偵測到記憶卡或隨身碟,傳送的檔案無處存放。插入一個再列印一次 — 在此之前,每一次歸檔的列印都會缺少縮圖與切片中繼資料。',
+        warn_internal_storage: '該選項已開啟且儲存媒體在位,但這台印表機仍把上一次列印留在了 Bambuddy 無法讀取的內部儲存中。H2 系列與 P2S 韌體就是這樣,任何設定都改變不了。列印會帶著名稱與時間歸檔,但沒有縮圖與切片中繼資料。',
       },
       port_rtsps: {
         title: '攝影機連接埠({{protocol}} {{port}})',

+ 38 - 11
frontend/src/pages/ArchivesPage.tsx

@@ -2811,6 +2811,24 @@ export function ArchivesPage() {
     localStorage.setItem('archiveNo3MFWarningDismissed', 'true');
     setNo3MFWarningDismissed(true);
   };
+  // Why the 3MF was missing decides what to tell the user, and the original
+  // single wording is wrong for two of the three cases: it sends H2-series and
+  // P2S owners to switch on a setting that is already on and would not have
+  // helped, and it blames the slicer when the real answer is an empty card
+  // slot (#2780). An unknown/absent reason keeps the original text.
+  const no3MFVariant =
+    no3MFWarning?.reason === 'internal_storage'
+      ? 'InternalStorage'
+      : no3MFWarning?.reason === 'no_external_storage'
+        ? 'NoExternalStorage'
+        : '';
+  // Nothing to link for the empty-slot case — "put a card in" is the whole fix.
+  const no3MFDocsHref =
+    no3MFWarning?.reason === 'internal_storage'
+      ? 'https://wiki.bambuddy.cool/reference/troubleshooting/#archive-card-has-only-a-name'
+      : no3MFWarning?.reason === 'no_external_storage'
+        ? null
+        : 'https://wiki.bambuddy.cool/getting-started/#step-4-enable-store-sent-files-on-external-storage';
   const [isSelectionMode, setIsSelectionMode] = useState(false);
   const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
   const [showBatchTag, setShowBatchTag] = useState(false);
@@ -3626,19 +3644,28 @@ export function ArchivesPage() {
           <AlertCircle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
           <div className="flex-1 min-w-0">
             <div className="text-sm font-medium text-amber-900 dark:text-amber-200">
-              {t('archives.no3mfBanner.title')}
+              {t(`archives.no3mfBanner.title${no3MFVariant}`)}
             </div>
             <div className="text-xs text-amber-800/90 dark:text-amber-200/80 mt-1">
-              {t('archives.no3mfBanner.body')}{' '}
-              <a
-                href="https://wiki.bambuddy.cool/getting-started/#step-4-enable-store-sent-files-on-external-storage"
-                target="_blank"
-                rel="noreferrer"
-                className="underline hover:text-amber-900 dark:hover:text-amber-100 inline-flex items-center gap-1"
-              >
-                {t('archives.no3mfBanner.docsLink')}
-                <ExternalLink className="w-3 h-3" />
-              </a>
+              {t(`archives.no3mfBanner.body${no3MFVariant}`)}
+              {no3MFDocsHref && (
+                <>
+                  {' '}
+                  <a
+                    href={no3MFDocsHref}
+                    target="_blank"
+                    rel="noreferrer"
+                    className="underline hover:text-amber-900 dark:hover:text-amber-100 inline-flex items-center gap-1"
+                  >
+                    {t(
+                      no3MFWarning?.reason === 'internal_storage'
+                        ? 'archives.no3mfBanner.docsLinkInternalStorage'
+                        : 'archives.no3mfBanner.docsLink',
+                    )}
+                    <ExternalLink className="w-3 h-3" />
+                  </a>
+                </>
+              )}
             </div>
           </div>
           <button

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-CwdtRDU5.js


+ 1 - 1
static/index.html

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

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů