Sfoglia il codice sorgente

fix(archives): come back for a 3MF whose transfer ran out of time (issue #3063)

The reporter's P1S had the sliced file on its card and was serving it. The 19MB
transfer just did not finish inside the budget while the printer was also running
its camera, its status messages and the upload of the job itself. Bambuddy wrote
an empty fallback archive and never looked again -- then downloaded that same file
successfully three times over the next two minutes and discarded every copy,
because the only code that would have attached one had already run.

The recovery machinery was there. It was armed for exactly one give-up, the FTPS
cool-off, on the grounds that the three storage verdicts are settled: a job on
internal eMMC never appears at any FTPS path, and sweeping for it again is what
where the file is demonstrably still on the card.

The sweep already had the signal and never used it. A file that is genuinely not
there is answered with 550, which surfaces as FileNotOnPrinterError and is caught
by name; a timeout returns falsy instead. So "the printer says no such file" and
"we never got a straight answer" are distinguishable without guessing, and only
the second schedules anything.

Not scheduled either for a 3MF that downloaded fine and turned out to be another
plate's. Recovery checks that a candidate is a readable 3MF but not which plate it
holds, and the names a retry would use are the same stale ones that fetched the
contradicted file -- so it would put back exactly what #2957 discards.

The ladder follows the cause: a cool-off has to expire, so its first attempt sits
past the 300s; nothing has to expire here, and this reporter's file completed 48
seconds after the budget was spent.

The archives banner gets its own wording for this, because the old text sends an
owner whose card is working to switch on a setting that is already on. It names
the Connection Timeout setting instead.
maziggy 1 giorno fa
parent
commit
a4cfbd4212

+ 1 - 0
CHANGELOG.md

@@ -29,6 +29,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
 
 ### Fixed
+- **An archive gave up on its 3MF for good after one slow transfer (#3063, reported by @dfrysinger)** — the reporter's P1S had the file on its card and was serving it; the 19 MB transfer just did not finish inside the budget while the printer was also running its camera, its status messages and the job upload. Bambuddy wrote an empty archive and never looked again — and then downloaded that same file successfully three times in the next two minutes, throwing each copy away, because the only code that would have attached one had already run. Recovery for a fallback archive existed but was armed for exactly one give-up, the FTPS cool-off. It now also covers a transfer that ran out of time: Bambuddy comes back after one, four and ten minutes and fills the row in where an attempt lands. The two are told apart by what the printer said — a file that genuinely is not on the card is answered with 550, and that answer does not improve with waiting, so nothing is scheduled for it. Nor for a 3MF that downloaded fine but turned out to be another plate's, where retrying would put back exactly what was just discarded. The archives banner has wording for this case too, because the old text sent an owner whose card was working to switch on a setting that was already on; it names the Connection Timeout setting instead.
 - **Items Printed could not be set to 0 after a total plate failure (#3051, reported by @tdavis75)** — a jam ruined everything on the plate while the printer reported the job a success, so the honest count of usable parts was zero; the field refused to go below one. The manual quantity override is what the documentation points at for exactly this correction, and a project's completed-items count sums that column, so there was no way to tell a project that a job produced nothing. The floor was in the edit dialog alone — the API had always stored whatever it was given, which also meant a negative count was accepted and would have subtracted from the project totals. Zero is now typeable and the column is bounded at zero, and the Filament Trends widget counts a zeroed archive as no prints rather than silently reading the 0 as "unset" and charging one.
 - **Bulk edit could not turn G-code injection on or off (#3058)** — every other per-item print option in the queue can be changed for a whole selection at once, but **Inject G-code** was only ever settable one item at a time, in the item's own edit dialog. The bulk dialog simply had no control for it, which on a queue of a few dozen jobs meant opening every single one to arm the start and end snippets an auto-print system needs — the thing bulk edit exists to avoid. It is now a tri-state next to **Auto power off after print**, unchanged by default like the rest, and it appears only once a G-code snippet has actually been saved for some printer model, matching the checkbox in the print dialog. Nothing changed on the server: the bulk endpoint has always accepted the field, so this was a missing control rather than a missing capability.
 - **AMS Filament Backup switched itself off with every print started from the queue (#3040, reported by @frnzzle)** — it never actually did: the printer had auto-refill on the whole time, and Bambuddy was reading its own request back as telemetry. Every `project_file` Bambuddy sent carried `"cfg": "0"`, a field Bambu Studio has never sent and the firmware ignores, but the printer echoes a command's fields back in its acknowledgement — and `cfg` is the device-config bitmask whose bit 18 is auto-refill. The acknowledgement was ingested as though it were a status frame, so 25 ms after every dispatch the badge flipped to off. On a P2S, H2C or X2D it flickered back a second later, because those repeat `cfg` in their periodic status; the P1S, A1, A1 Mini and A2L send it only in a full status dump, which arrives on connect and on Force Refresh and otherwise not at all, so there the wrong value stood until someone toggled it by hand. That mattered beyond the badge: the "prefer lowest remaining" sort is deliberately skipped while the printer reports backup off, since without a second spool to fall back on, feeding a print from the emptiest one risks running dry mid-job — which is why near-empty spools were being left untouched, the symptom that opened the report. On the A1 family it was worse still: they report no `cfg` at all, and the state is meant to stay "unknown" so that behaviour is left exactly as it was before the feature existed; the echo turned that into a definite "off" on the first queue print. Command acknowledgements are no longer read as status — for the backup bit or for the per-job timelapse flag they also echo — and the `cfg` field is gone from the print command, which is not a place to be writing printer settings from.

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

@@ -40,6 +40,7 @@ from backend.app.services.bambu_ftp import ftps_handshake_blocked, list_files_re
 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_FTP_TRANSFER_FAILED,
     REASON_FTPS_COOLOFF,
     REASON_INTERNAL_HISTORY,
     REASON_INTERNAL_STORAGE,
@@ -598,6 +599,13 @@ async def no_3mf_warning(
     # lands, so a row still carrying this slug is one where the retry failed too
     # -- a printer whose file service is still refusing, days later.
     #
+    # REASON_FTP_TRANSFER_FAILED sits second for the same reasons and one more:
+    # it is the only slug here whose remedy is a Bambuddy setting rather than a
+    # slicer one or a card. It ranks below the cool-off because a printer that
+    # will not complete a TLS handshake is the worse fault of the two, and its
+    # own retry (#3063) clears the row the same way, so a row still carrying
+    # this slug is one where three later attempts also ran out of time.
+    #
     # REASON_INTERNAL_HISTORY comes last on purpose, even though it is the
     # narrowest: it is the one cause with no remedy at all -- the file was
     # already on the printer, in an area port 990 does not serve. The two ahead
@@ -605,6 +613,7 @@ async def no_3mf_warning(
     # both, the actionable explanation is the one worth the banner (#1820).
     for candidate in (
         REASON_FTPS_COOLOFF,
+        REASON_FTP_TRANSFER_FAILED,
         REASON_INTERNAL_STORAGE,
         REASON_NO_EXTERNAL_STORAGE,
         REASON_INTERNAL_HISTORY,

+ 90 - 19
backend/app/main.py

@@ -115,6 +115,7 @@ 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 (
+    REASON_FTP_TRANSFER_FAILED,
     REASON_FTPS_COOLOFF,
     external_storage_present,
     ftp_probe_paths,
@@ -3092,6 +3093,14 @@ async def _restore_printable_objects(printer_id: int, state, db, logger) -> None
 # armed a fresh one. Module-level so tests can shrink them.
 _FALLBACK_3MF_RETRY_DELAYS_SECONDS: tuple[float, ...] = (310.0, 620.0)
 
+# Retry ladder for the other temporary give-up: the file service answered and
+# the transfer still did not finish, which at print start is usually the printer
+# serving MQTT, the camera and a job upload at the same time (#3063). Nothing has
+# to expire here, so the first attempt comes early -- #3063's reporter had the
+# same 19MB file complete 48 seconds after the download budget ran out. The later
+# two cover a printer that stays busy well into the print.
+_FALLBACK_3MF_TRANSFER_RETRY_DELAYS_SECONDS: tuple[float, ...] = (60.0, 240.0, 600.0)
+
 # printer_id -> the in-flight retry task, so print completion can cancel it.
 _fallback_3mf_retry_tasks: dict[int, asyncio.Task] = {}
 
@@ -3225,16 +3234,36 @@ async def try_recover_fallback_archive(printer_id: int, name: str, path: Path) -
         return False
 
 
-def _schedule_fallback_3mf_retry(printer_id: int, archive_id: int, filenames: list[str]) -> None:
-    """Re-attempt the 3MF download after the printer's FTPS cool-off clears."""
+def _schedule_fallback_3mf_retry(
+    printer_id: int,
+    archive_id: int,
+    filenames: list[str],
+    delays: tuple[float, ...] | None = None,
+    reason: str = REASON_FTPS_COOLOFF,
+) -> None:
+    """Re-attempt the 3MF download after a temporary give-up.
+
+    ``reason`` says which give-up this is, and picks the default ladder: an
+    FTPS cool-off has to be waited out, while a transfer that timed out under
+    contention is worth asking about again straight away (#3063). It is only
+    read for the ladder and the log line -- the retry itself is identical, since
+    in both cases the file is on the printer and the last attempt at it failed
+    for a reason that does not last.
+    """
 
     logger = logging.getLogger(__name__)
+    if delays is None:
+        delays = (
+            _FALLBACK_3MF_TRANSFER_RETRY_DELAYS_SECONDS
+            if reason == REASON_FTP_TRANSFER_FAILED
+            else _FALLBACK_3MF_RETRY_DELAYS_SECONDS
+        )
 
     async def _retry() -> None:
         from backend.app.models.archive import PrintArchive
         from backend.app.models.printer import Printer
 
-        for delay in _FALLBACK_3MF_RETRY_DELAYS_SECONDS:
+        for delay in delays:
             await asyncio.sleep(delay)
 
             async with async_session() as db:
@@ -3318,9 +3347,11 @@ def _schedule_fallback_3mf_retry(printer_id: int, archive_id: int, filenames: li
     task = asyncio.create_task(_guarded())
     _fallback_3mf_retry_tasks[printer_id] = task
     logger.info(
-        "[RECOVER] Archive %s has no 3MF because printer %s was in its FTPS cool-off; will retry",
+        "[RECOVER] Archive %s has no 3MF (%s) and the file should still be on printer %s; will retry in %s",
         archive_id,
+        reason,
         printer_id,
+        ", ".join(f"{d:g}s" for d in delays),
     )
 
 
@@ -4020,6 +4051,14 @@ async def on_print_start(printer_id: int, data: dict):
         # in minutes with the file still sitting on the printer.
         blocked_by_ftps_cooloff = False
 
+        # Set when a probe reached the printer and still came back without the
+        # file -- a timeout mid-transfer, a refused connection, anything that is
+        # not a clean "not here". A 550 raises FileNotOnPrinterError and is
+        # caught by name below, so a file that genuinely is not on the card
+        # leaves this False and schedules nothing. Anything else means the
+        # transfer, not the file, is what failed, and that does not last (#3063).
+        ftp_transfer_failed = False
+
         # Get FTP retry settings
         ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
 
@@ -4159,11 +4198,17 @@ async def on_print_start(printer_id: int, data: dict):
                         # runs next) doesn't refetch the same 36MB over FTP.
                         cache_3mf_download(printer_id, try_filename, temp_path)
                         break
+                    # with_ftp_retry returns None once it has spent its budget,
+                    # and download_file_async returns False on a timeout, so an
+                    # exhausted transfer arrives here rather than as an
+                    # exception (#3063).
+                    ftp_transfer_failed = True
                 except FileNotOnPrinterError:
                     # 550 — file isn't at this path. Advance to next candidate
                     # without burning the retry budget.
                     logger.debug("3MF not at %s (550), trying next path", remote_path)
                 except Exception as e:
+                    ftp_transfer_failed = True
                     logger.debug("FTP download failed for %s: %s", remote_path, e)
 
             if downloaded_filename or ftps_handshake_blocked(printer.ip_address):
@@ -4236,6 +4281,9 @@ async def on_print_start(printer_id: int, data: dict):
                                 logger.info("Found and downloaded from %s: %s", search_dir, fname)
                                 cache_3mf_download(printer_id, fname, temp_path)
                                 break
+                            # The listing named the file, so it is on the card;
+                            # only the transfer failed (#3063).
+                            ftp_transfer_failed = True
                 except Exception as e:
                     logger.debug("Failed to list %s: %s", search_dir, e)
 
@@ -4340,6 +4388,15 @@ async def on_print_start(printer_id: int, data: dict):
                         pass
                     temp_path = None
                     downloaded_filename = None
+                    # Whatever the sweep's transport did earlier, it is not why
+                    # this archive ends up empty: a 3MF downloaded fine, it was
+                    # just the wrong plate. Retrying would re-fetch that same
+                    # contradicted file under the same stale names and hand it
+                    # to _recover_fallback_archive, which checks that a
+                    # candidate is a readable 3MF but not which plate it is --
+                    # so the row would be filled in with another plate's
+                    # filament and cost, the exact swap #2957 removed (#3063).
+                    ftp_transfer_failed = False
                     # Override the stale subtask_name so the fallback archive's
                     # print_name reflects the correct plate. Prefer the swapped
                     # name when we have one; otherwise let filename win.
@@ -4355,6 +4412,20 @@ async def on_print_start(printer_id: int, data: dict):
             try:
                 from backend.app.models.archive import PrintArchive
 
+                # Why the card is empty. The two temporary causes outrank the
+                # storage verdict because they say the sweep never got a fair
+                # answer: a cool-off skipped it at the transport, and a failed
+                # transfer reached the printer but never finished. Either way
+                # the file is still on the card, so reporting where the printer
+                # files its jobs would describe a setting that is not the
+                # problem (#2957, #3063).
+                if blocked_by_ftps_cooloff:
+                    no_3mf_reason = REASON_FTPS_COOLOFF
+                elif storage.reachable and ftp_transfer_failed:
+                    no_3mf_reason = REASON_FTP_TRANSFER_FAILED
+                else:
+                    no_3mf_reason = storage.reason
+
                 # Derive print name from subtask_name or filename
                 print_name = subtask_name or filename
                 if print_name:
@@ -4397,14 +4468,11 @@ async def on_print_start(printer_id: int, data: dict):
                     filament_color=mqtt_filament_meta.get("filament_color"),
                     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).
-                        # A cool-off outranks the storage verdict: the sweep was
-                        # skipped at the transport, so the verdict never got to
-                        # be tested, and reporting it would blame the SD card
-                        # for a TLS handshake (#2957).
-                        "no_3mf_reason": REASON_FTPS_COOLOFF if blocked_by_ftps_cooloff else storage.reason,
+                        # Why the card is empty, when we know -- see above. The
+                        # banner reads this to stop telling H2/P2 owners to
+                        # switch on a setting that is already on and would not
+                        # have helped (#2780).
+                        "no_3mf_reason": no_3mf_reason,
                         "original_subtask": subtask_name,
                         "_print_data": data,
                     },
@@ -4465,13 +4533,15 @@ async def on_print_start(printer_id: int, data: dict):
                 except Exception as e:
                     logger.debug("[SPOOLMAN] Could not store tracking for fallback archive: %s", e)
 
-                # A cool-off give-up is temporary and the file is on the
-                # printer — come back for it once the handshake block clears
-                # (#2957). Deliberately not scheduled for a storage verdict:
-                # a file on internal eMMC will not appear at any FTPS path
-                # however long we wait, and retrying it is exactly the sweep
-                # #2780 removed.
-                if blocked_by_ftps_cooloff and possible_names:
+                # Both temporary give-ups are worth coming back for, and for
+                # the same reason: the file is on the printer and the last look
+                # failed at the transport rather than finding nothing. One waits
+                # out the handshake block (#2957), the other waits for the
+                # printer to stop being busy (#3063). Deliberately not scheduled
+                # for a storage verdict: a file on internal eMMC will not appear
+                # at any FTPS path however long we wait, and retrying it is
+                # exactly the sweep #2780 removed.
+                if no_3mf_reason in (REASON_FTPS_COOLOFF, REASON_FTP_TRANSFER_FAILED) and possible_names:
                     # `possible_names`, not the raw MQTT strings: it is the exact
                     # list this flow just tried, already stripped of any path
                     # (`filename` arrives as "/data/Metadata/plate_1.gcode" on
@@ -4480,6 +4550,7 @@ async def on_print_start(printer_id: int, data: dict):
                         printer_id=printer_id,
                         archive_id=fallback_archive.id,
                         filenames=list(possible_names),
+                        reason=no_3mf_reason,
                     )
 
                 # Send notification without archive data (file not found)

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

@@ -84,6 +84,18 @@ REASON_INTERNAL_HISTORY = "internal_history"
 # retry is worth scheduling (#2957).
 REASON_FTPS_COOLOFF = "ftps_cooloff"
 
+# Also not a storage verdict, and the file's location was never in question
+# here either: the print went to external storage, FTPS served it, and the
+# transfer still did not finish inside its budget. At print start the printer is
+# also handling MQTT, the camera and the job upload, and a large 3MF does not
+# reliably complete against that -- #3063's reporter watched the same 19MB file
+# download successfully three times in the two minutes after the archive flow
+# gave up on it. Like the cool-off above and unlike the three storage verdicts,
+# this one is temporary and worth a retry; unlike the cool-off, nothing has to
+# expire first. Stamped by the print-start handler, which is the only place that
+# knows an attempt was made and failed in transit rather than answering 550.
+REASON_FTP_TRANSFER_FAILED = "ftp_transfer_failed"
+
 # Where a sliced file has ever been found over FTPS, in the order the sweep in
 # `main.py` tries them -- root first, which is where A1/P1-series uploads land
 # (#972), then `/cache`, which is where the H2D keeps its copy of an eMMC job

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

@@ -1595,6 +1595,49 @@ class TestNo3MFWarningReason:
 
         assert response.json() == {"has_fallback": True, "reason": "no_external_storage"}
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_transfer_that_ran_out_of_time_is_reported(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """#3063's P1S had the file on its card and served it three times in the
+        two minutes after the archive flow gave up on it. Told to switch on
+        "Store sent files on external storage", that reporter would be switching
+        on a setting that was already on and had already worked.
+        """
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            extra_data={"no_3mf_available": True, "no_3mf_reason": "ftp_transfer_failed"},
+        )
+
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+
+        assert response.json() == {"has_fallback": True, "reason": "ftp_transfer_failed"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_slow_transfer_outranks_the_settled_causes_but_not_a_refused_handshake(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Both temporary causes describe a file still sitting on the card, so
+        both outrank the three that describe an install working as configured.
+        Between the two, a printer that will not complete a TLS handshake is the
+        worse fault and keeps the banner.
+        """
+        printer = await printer_factory()
+        for reason in ("internal_storage", "no_external_storage", "internal_history"):
+            await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": reason})
+        await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "ftp_transfer_failed"})
+
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+        assert response.json() == {"has_fallback": True, "reason": "ftp_transfer_failed"}
+
+        await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "ftps_cooloff"})
+
+        response = await async_client.get("/api/v1/archives/no-3mf-warning")
+        assert response.json() == {"has_fallback": True, "reason": "ftps_cooloff"}
+
 
 class TestPrintLogEntryDelete:
     """#1687: per-row delete on the Print Log page.

+ 371 - 0
backend/tests/unit/test_fallback_3mf_transfer_retry_3063.py

@@ -0,0 +1,371 @@
+"""A transfer that ran out of time is a temporary give-up too (#3063).
+
+The reporter's P1S was sent a 19 MB 3MF, the card had it, and FTPS served it --
+just not inside the 30s budget plus its 30s grace, four times over, while the
+printer was also running MQTT, the camera and the job upload at print start.
+Bambuddy wrote an empty fallback archive at 03:14:23. The same file then
+downloaded successfully at 03:15:11, 03:16:09 and 03:16:36, and every one of
+those copies was thrown away, because the only code that would have attached one
+had already given up.
+
+#2957 built the machinery to fill a fallback archive in after the fact, but
+armed it for exactly one give-up: the FTPS cool-off. Everything else was treated
+as settled, which is right for the three storage verdicts -- a job on internal
+eMMC never appears at any FTPS path -- and wrong here, where the file is on the
+card and the only thing that failed was the transfer.
+
+The discrimination these tests pin is the one the sweep already has and never
+used: a file that is genuinely not there answers 550, which surfaces as
+FileNotOnPrinterError and is caught by name. A timeout returns falsy instead --
+``with_ftp_retry`` hands back None once its budget is spent -- so "we never got a
+straight answer" and "the printer says no such file" are distinguishable without
+guessing.
+"""
+
+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,
+)
+from backend.app.services.print_storage import REASON_FTP_TRANSFER_FAILED, REASON_FTPS_COOLOFF
+
+pytestmark = pytest.mark.unit
+
+DISPATCH = "/data/Metadata/plate_1.gcode"
+SUBTASK = "Fan_Shroud"
+
+
+@pytest.fixture(autouse=True)
+def _clear_dicts():
+    dicts = (
+        _expected_prints,
+        _expected_print_registered_at,
+        _expected_print_creators,
+        _print_ams_mappings,
+        _active_prints,
+        _timelapse_baselines,
+    )
+    for d in dicts:
+        d.clear()
+    yield
+    for d in dicts:
+        d.clear()
+
+
+def _printer():
+    printer = MagicMock()
+    printer.id = 1
+    printer.auto_archive = True
+    printer.external_camera_enabled = False
+    printer.external_camera_url = None
+    # Every unset MagicMock attribute is truthy, and leaving this one implicit
+    # runs the plate-detection camera grab against a printer that is not there.
+    printer.plate_detection_enabled = False
+    printer.name = "P1S"
+    printer.model = "P1S"
+    printer.ip_address = "172.25.12.149"
+    printer.access_code = "12345678"
+    return printer
+
+
+async def _run_print_start(download, peek_plate=None):
+    """Drive on_print_start's fallback path for a print on external storage.
+
+    Returns ``(added_rows, schedule_mock)``. The card is present and the
+    dispatch says ``ftp://``, so the storage verdict is reachable and the sweep
+    runs -- what differs between tests is only how ``download`` fails.
+    """
+    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]))),
+            )
+        return MagicMock(
+            scalar_one_or_none=MagicMock(return_value=None),
+            scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
+        )
+
+    added: list = []
+    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)
+
+    schedule = MagicMock()
+    state = MagicMock(
+        current_project_url=f"ftp://{SUBTASK}.gcode.3mf",
+        sdcard=True,
+        sdcard_reported=True,
+    )
+
+    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.download_file_try_paths_async", new=AsyncMock(return_value=None)),
+        patch("backend.app.main.get_cached_3mf", return_value=None),
+        patch("backend.app.main.cache_3mf_download"),
+        patch("backend.app.main.peek_plate_index_in_3mf", return_value=peek_plate),
+        # Imported inside the function, so patching it anywhere else lets the
+        # directory walk open real sockets and the test hangs on connect.
+        patch("backend.app.services.bambu_ftp.list_files_async", new=AsyncMock(return_value=[])),
+        patch("backend.app.main.ftps_handshake_blocked", return_value=False),
+        # Retry off, so `download` is called directly and its failure mode is
+        # the one under test rather than with_ftp_retry's summary of it.
+        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),
+        # Real, it would spawn a task that outlives the test by a minute.
+        patch("backend.app.main._schedule_fallback_3mf_retry", new=schedule),
+    ):
+        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()
+        # Awaited between creating the fallback row and scheduling its retry: a
+        # plain MagicMock here raises, the handler swallows it, and every
+        # assertion about the retry passes vacuously.
+        ws.send_archive_created = AsyncMock()
+        relay.on_print_start = AsyncMock()
+        pm.get_status = MagicMock(return_value=state)
+        pm.get_printer = MagicMock(return_value=MagicMock(serial_number="TEST3063"))
+
+        from backend.app.main import on_print_start
+
+        await on_print_start(1, {"filename": DISPATCH, "subtask_name": SUBTASK})
+
+    return added, schedule
+
+
+def _fallback(added):
+    for row in added:
+        extra = getattr(row, "extra_data", None)
+        if isinstance(extra, dict) and extra.get("no_3mf_available"):
+            return row
+    return None
+
+
+class TestATimedOutTransferIsWorthComingBackFor:
+    @pytest.mark.asyncio
+    async def test_the_archive_records_the_transfer_as_the_cause(self):
+        """Not `None`, which is the slug for "the slicer left nothing on the
+        card" and sends this reporter to a setting that was already on."""
+        added, _schedule = await _run_print_start(AsyncMock(return_value=False))
+
+        assert _fallback(added).extra_data["no_3mf_reason"] == REASON_FTP_TRANSFER_FAILED
+
+    @pytest.mark.asyncio
+    async def test_a_retry_is_scheduled_with_the_names_the_sweep_just_tried(self):
+        added, schedule = await _run_print_start(AsyncMock(return_value=False))
+
+        schedule.assert_called_once()
+        kwargs = schedule.call_args.kwargs
+        assert kwargs["reason"] == REASON_FTP_TRANSFER_FAILED
+        assert f"{SUBTASK}.gcode.3mf" in kwargs["filenames"]
+        assert _fallback(added) is not None
+
+    @pytest.mark.asyncio
+    async def test_a_connection_error_counts_as_a_failed_transfer_too(self):
+        """A refused or dropped connection is not the printer saying the file
+        is absent, and it does not last any longer than a timeout does."""
+        added, schedule = await _run_print_start(AsyncMock(side_effect=OSError("connection reset")))
+
+        assert _fallback(added).extra_data["no_3mf_reason"] == REASON_FTP_TRANSFER_FAILED
+        schedule.assert_called_once()
+
+
+class TestAFileThatIsNotThereIsStillSettled:
+    @pytest.mark.asyncio
+    async def test_a_550_from_every_path_schedules_nothing(self):
+        """The regression guard on the whole change. 550 is the printer
+        answering the question, and no amount of waiting changes the answer --
+        retrying it is the sweep #2780 removed for costing an install 1813
+        failed connections in a day."""
+        from backend.app.services.bambu_ftp import FileNotOnPrinterError
+
+        added, schedule = await _run_print_start(AsyncMock(side_effect=FileNotOnPrinterError("550")))
+
+        assert _fallback(added).extra_data["no_3mf_reason"] is None
+        schedule.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_a_download_that_produced_the_wrong_plate_schedules_nothing(self):
+        """A 3MF arrived, so the transport is not what failed here -- it was the
+        wrong plate, and #2957 discards it rather than archive another plate's
+        filament and cost against this print.
+
+        The names the sweep would retry with are the same stale ones that
+        fetched the contradicted file, and `_recover_fallback_archive` checks
+        that a candidate is a readable 3MF but not which plate it holds. So a
+        retry here would put back exactly what was just thrown away.
+        """
+        # First path times out, the second serves a file -- for plate 2, while
+        # the dispatch says plate 1. Without the reset, that first timeout would
+        # be enough to arm a retry.
+        download = AsyncMock(side_effect=[False, True, True, True, True, True])
+
+        added, schedule = await _run_print_start(download, peek_plate=2)
+
+        assert _fallback(added) is not None
+        schedule.assert_not_called()
+
+
+class TestTheRetryActuallyFillsTheArchiveIn:
+    """The ladder is only half of it -- the pass it schedules has to land."""
+
+    @pytest.mark.asyncio
+    async def test_the_reporters_sequence_end_to_end(self, test_engine, tmp_path, monkeypatch):
+        """Give up on the transfer, then let the same file turn up a minute
+        later exactly as it did for the reporter, and the empty row is filled
+        in rather than left for good.
+
+        Driven through the real ``_schedule_fallback_3mf_retry`` rather than a
+        mock of it, because the thing #3063 reports is not that nothing was
+        scheduled -- it is that nothing ever attached the file.
+        """
+        import asyncio
+        import zipfile
+
+        from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+        from backend.app import main as main_module
+        from backend.app.models.archive import PrintArchive
+        from backend.app.models.printer import Printer
+        from backend.app.services import bambu_ftp
+
+        maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+        async with maker() as db:
+            printer = Printer(
+                name="P1S",
+                serial_number="01P00A3B1200579",
+                ip_address="172.25.12.149",
+                access_code="12345678",
+                model="P1S",
+            )
+            db.add(printer)
+            await db.commit()
+            await db.refresh(printer)
+            archive = PrintArchive(
+                printer_id=printer.id,
+                filename=f"{SUBTASK}.gcode.3mf",
+                file_path="",
+                file_size=0,
+                print_name=SUBTASK,
+                status="printing",
+                extra_data={
+                    "no_3mf_available": True,
+                    "no_3mf_reason": REASON_FTP_TRANSFER_FAILED,
+                    "_print_data": {"filename": f"{SUBTASK}.gcode.3mf"},
+                },
+            )
+            db.add(archive)
+            await db.commit()
+            await db.refresh(archive)
+            printer_id, archive_id = printer.id, archive.id
+
+        source = tmp_path / "temp" / f"{SUBTASK}.gcode.3mf"
+        source.parent.mkdir(parents=True, exist_ok=True)
+        with zipfile.ZipFile(source, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr(
+                "Metadata/slice_info.config",
+                "<?xml version='1.0' encoding='UTF-8'?>"
+                "<config><plate>"
+                "<metadata key='index' value='1'/>"
+                "<metadata key='prediction' value='3600'/>"
+                "<metadata key='weight' value='42.5'/>"
+                "<filament id='1' type='PLA' color='#00AE42' used_g='42.5' used_m='14.2'/>"
+                "</plate></config>",
+            )
+            zf.writestr("3D/3dmodel.model", "<model/>")
+
+        # The file turns up between the give-up and the first retry -- the
+        # cover endpoint pulling it for a thumbnail, as it did at 03:15:11.
+        bambu_ftp.cache_3mf_download(printer_id, f"{SUBTASK}.gcode.3mf", source)
+        monkeypatch.setattr(main_module, "_FALLBACK_3MF_TRANSFER_RETRY_DELAYS_SECONDS", (0.01,))
+
+        try:
+            with patch.object(main_module, "async_session", maker):
+                main_module._schedule_fallback_3mf_retry(
+                    printer_id=printer_id,
+                    archive_id=archive_id,
+                    filenames=[f"{SUBTASK}.gcode.3mf"],
+                    reason=REASON_FTP_TRANSFER_FAILED,
+                )
+                await asyncio.wait_for(main_module._fallback_3mf_retry_tasks[printer_id], timeout=5)
+        finally:
+            bambu_ftp.clear_3mf_cache(printer_id, delete_files=False)
+
+        async with maker() as db:
+            recovered = await db.get(PrintArchive, archive_id)
+            assert recovered.file_path, "the row still has no 3MF"
+            assert recovered.file_size > 0
+            # The markers are what the archives banner counts, so they have to
+            # go or the install keeps being told about a print that is fine.
+            assert not recovered.extra_data.get("no_3mf_available")
+            assert not recovered.extra_data.get("no_3mf_reason")
+
+
+class TestTheLadderSuitsTheCause:
+    def test_the_transfer_ladder_starts_well_before_the_cooloff_one(self):
+        """A cool-off has to expire first -- 300s of it -- so #2957 places its
+        first attempt past that. Nothing has to expire here: #3063's file
+        completed 48 seconds after the budget ran out, and waiting five minutes
+        to ask would mean the cover endpoint is the only thing that ever
+        recovers these.
+        """
+        from backend.app.main import (
+            _FALLBACK_3MF_RETRY_DELAYS_SECONDS,
+            _FALLBACK_3MF_TRANSFER_RETRY_DELAYS_SECONDS,
+        )
+
+        assert _FALLBACK_3MF_TRANSFER_RETRY_DELAYS_SECONDS[0] < _FALLBACK_3MF_RETRY_DELAYS_SECONDS[0]
+        assert _FALLBACK_3MF_TRANSFER_RETRY_DELAYS_SECONDS[0] <= 60.0
+
+    @pytest.mark.asyncio
+    async def test_each_cause_gets_its_own_ladder(self, monkeypatch):
+        """One scheduler, two callers. Passing the wrong reason would make a
+        cool-off retry fire while the cool-off is still running, which the task
+        can only answer by deferring."""
+        import asyncio
+
+        from backend.app import main as main_module
+
+        slept: list[float] = []
+
+        async def _record(delay):
+            slept.append(delay)
+            raise asyncio.CancelledError
+
+        monkeypatch.setattr(main_module.asyncio, "sleep", _record)
+
+        for reason, expected in (
+            (REASON_FTPS_COOLOFF, main_module._FALLBACK_3MF_RETRY_DELAYS_SECONDS[0]),
+            (REASON_FTP_TRANSFER_FAILED, main_module._FALLBACK_3MF_TRANSFER_RETRY_DELAYS_SECONDS[0]),
+        ):
+            slept.clear()
+            main_module._schedule_fallback_3mf_retry(printer_id=1, archive_id=1, filenames=["x.3mf"], reason=reason)
+            task = main_module._fallback_3mf_retry_tasks[1]
+            with pytest.raises(asyncio.CancelledError):
+                await task
+            assert slept == [expected]

+ 36 - 9
backend/tests/unit/test_print_start_skips_unreachable_storage_2780.py

@@ -82,13 +82,21 @@ def _state(current_project_url, sdcard=True, sdcard_reported=True):
     )
 
 
-async def _run_print_start(state, added, probe_hit=None):
+async def _run_print_start(state, added, probe_hit=None, download_fails_in_transit=False):
     """Drive on_print_start for a print with no matching archive, capturing
     whatever rows it adds and whether it reached the FTP layer.
 
     ``probe_hit`` is the path the bounded internal-storage probe serves the
     file from (#2856), or None for the #2780 case where the file really is out
     of reach.
+
+    ``download_fails_in_transit`` picks which kind of empty-handed sweep to
+    model. The default is the honest "the file is not on this card": the FTP
+    server answers 550 and the client raises FileNotOnPrinterError. True instead
+    returns falsy from every attempt, which is what a timeout looks like from
+    here -- the printer had the file and the transfer ran out of time (#3063).
+    The two now lead to different reasons on the archive, so a test that means
+    one must not mock the other.
     """
     printer = _printer()
 
@@ -114,8 +122,14 @@ async def _run_print_start(state, added, probe_hit=None):
     session.refresh = AsyncMock()
     session.add = MagicMock(side_effect=added.append)
 
-    download = AsyncMock(return_value=False)
+    from backend.app.services.bambu_ftp import FileNotOnPrinterError
+
+    if download_fails_in_transit:
+        download = AsyncMock(return_value=False)
+    else:
+        download = AsyncMock(side_effect=FileNotOnPrinterError("550"))
     probe = AsyncMock(return_value=probe_hit)
+    schedule = MagicMock()
 
     with (
         patch("backend.app.main.async_session") as session_maker,
@@ -138,12 +152,18 @@ async def _run_print_start(state, added, probe_hit=None):
         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),
+        # Real, it would spawn a task that sleeps a minute past the end of the
+        # test; what each test cares about is whether it was called at all.
+        patch("backend.app.main._schedule_fallback_3mf_retry", new=schedule),
     ):
         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()
+        # Awaited after the fallback row is added, so without it the handler
+        # raises there and never reaches what follows.
+        ws.send_archive_created = AsyncMock()
         relay.on_print_start = AsyncMock()
         pm.get_status = MagicMock(return_value=state)
         pm.get_printer = MagicMock(return_value=MagicMock(serial_number="TEST2780"))
@@ -155,7 +175,7 @@ async def _run_print_start(state, added, probe_hit=None):
             {"filename": "/data/Metadata/plate_1.gcode", "subtask_name": "Halterung"},
         )
 
-    return download, retry, probe
+    return download, retry, probe, schedule
 
 
 def _fallback(added):
@@ -174,7 +194,7 @@ async def test_a_print_on_internal_storage_probes_once_instead_of_sweeping():
     against the sweep's ~110 that cannot succeed."""
     added = []
 
-    download, retry, probe = await _run_print_start(_state("brtc://emmc/Halterung.gcode.3mf"), added)
+    download, retry, probe, _schedule = await _run_print_start(_state("brtc://emmc/Halterung.gcode.3mf"), added)
 
     probe.assert_awaited_once()
     assert probe.await_args.args[2] == [
@@ -198,7 +218,7 @@ async def test_an_empty_slot_never_touches_ftp():
     one stays a pure short-circuit."""
     added = []
 
-    download, retry, probe = await _run_print_start(_state(None, sdcard=False, sdcard_reported=True), added)
+    download, retry, probe, _schedule = await _run_print_start(_state(None, sdcard=False, sdcard_reported=True), added)
 
     download.assert_not_called()
     retry.assert_not_called()
@@ -212,7 +232,7 @@ async def test_a_print_on_external_storage_still_sweeps():
     the download exactly as it did before the gate existed."""
     added = []
 
-    download, _retry, _probe = await _run_print_start(_state("ftp://Halterung.gcode.3mf"), added)
+    download, _retry, _probe, _schedule = await _run_print_start(_state("ftp://Halterung.gcode.3mf"), added)
 
     download.assert_called()
 
@@ -224,7 +244,9 @@ async def test_a_printer_that_said_nothing_still_sweeps():
     either -- that install must behave exactly as before."""
     added = []
 
-    download, _retry, _probe = await _run_print_start(_state(None, sdcard=False, sdcard_reported=False), added)
+    download, _retry, _probe, _schedule = await _run_print_start(
+        _state(None, sdcard=False, sdcard_reported=False), added
+    )
 
     download.assert_called()
 
@@ -233,9 +255,14 @@ async def test_a_printer_that_said_nothing_still_sweeps():
 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."""
+    advice is right for it.
+
+    Every path answered 550, which is the printer saying the file is not there.
+    Nothing about that improves with time, so no retry is scheduled (#3063).
+    """
     added = []
 
-    await _run_print_start(_state("ftp://Halterung.gcode.3mf"), added)
+    _download, _retry, _probe, schedule = await _run_print_start(_state("ftp://Halterung.gcode.3mf"), added)
 
     assert _fallback(added).extra_data["no_3mf_reason"] is None
+    schedule.assert_not_called()

+ 23 - 1
frontend/src/__tests__/pages/ArchivesNo3MFBanner.test.tsx

@@ -113,6 +113,21 @@ describe('ArchivesPage no-3MF banner', () => {
     expect(screen.getByText('Why this happens')).toBeInTheDocument();
   });
 
+  it('reports a transfer that ran out of time as the transfer, not as the slicer', async () => {
+    // #3063: the card had the file and the printer served it three times in the
+    // two minutes after Bambuddy gave up. Telling that owner to switch on
+    // "Store sent files on external storage" describes a setting that was
+    // already on and had already worked.
+    mockWarning({ has_fallback: true, reason: 'ftp_transfer_failed' });
+
+    render(<ArchivesPage />);
+
+    expect(await screen.findByText(/file transfer ran out of time/i)).toBeInTheDocument();
+    expect(screen.queryByText('See install step 4')).not.toBeInTheDocument();
+    expect(screen.queryByText(/Store sent files on external storage/i)).not.toBeInTheDocument();
+    expect(screen.getByText('Why this happens')).toBeInTheDocument();
+  });
+
   it('shows nothing at all when no print fell back', async () => {
     mockWarning({ has_fallback: false, reason: null });
 
@@ -128,7 +143,14 @@ describe('ArchivesPage no-3MF banner', () => {
     // 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', 'internal_history', 'ftps_cooloff']) {
+    for (const reason of [
+      null,
+      'internal_storage',
+      'no_external_storage',
+      'internal_history',
+      'ftps_cooloff',
+      'ftp_transfer_failed',
+    ]) {
       localStorage.clear();
       mockWarning({ has_fallback: true, reason });
 

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

@@ -5051,7 +5051,13 @@ export const api = {
   getNo3MFWarning: () =>
     request<{
       has_fallback: boolean;
-      reason: 'ftps_cooloff' | 'internal_storage' | 'no_external_storage' | 'internal_history' | null;
+      reason:
+        | 'ftps_cooloff'
+        | 'ftp_transfer_failed'
+        | 'internal_storage'
+        | 'no_external_storage'
+        | 'internal_history'
+        | null;
     }>(
       '/archives/no-3mf-warning',
     ),

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

@@ -908,6 +908,8 @@ export default {
       bodyInternalHistory: 'Diese Drucke liefen aus der eigenen Bibliothek des Druckers — ein erneuter Druck über sein Display, ein Start aus Handy oder eine früher gesendete und später gedruckte Datei. Bambuddy liest Druckdateien über FTP, und das bedient nur Karte oder Stick, während der Drucker diese Bibliothek in einem Bereich ablegt, den FTP nicht erreicht — es gab also keine 3MF zu lesen. Keine Slicer-Einstellung ändert das, denn für diese Drucke wurde nichts gesendet. Sie werden weiterhin mit Namen und Zeiten archiviert, und unter "Archiv bearbeiten" lässt sich das verbrauchte Filament von Hand eintragen. Für ein vollständiges Archiv starten Sie den Druck stattdessen aus Bambuddy oder aus Ihrem Slicer.',
       titleFtpsCooloff: 'Einige kürzliche Drucke konnten nicht archiviert werden — der Drucker hat die Dateiverbindung abgewiesen',
       bodyFtpsCooloff: 'Bambuddy hat den Dateiübertragungs-Port des Druckers (FTPS 990) geöffnet, und der Drucker hat mit etwas geantwortet, das kein TLS ist. Deshalb ließ sich nichts von ihm lesen. Diese Drucke sind weiterhin mit Namen und Zeiten archiviert, nur ohne Vorschaubild und Slicer-Metadaten. Das ist keine Slicer-Einstellung und nichts, was Sie geändert haben — dieselben Modelle und Firmware-Stände laufen auf anderen Installationen normal, und ein betroffener Drucker arbeitet später meist von selbst wieder. Nach einem solchen Fehler pausiert Bambuddy die Übertragungen zu diesem Drucker fünf Minuten lang und holt die Datei nach Ablauf der Pause erneut; eine kurze Episode behebt sich damit von selbst, eine weiterhin leere Karte bedeutet, dass die Abweisung länger anhielt als der zweite Versuch. Was das auslöst, ist noch nicht bekannt. Wenn es wiederholt auftritt, aktivieren Sie auf der Seite System die Debug-Protokollierung und hängen Sie ein Support-Paket an Ihre Meldung an.',
+      titleFtpTransferFailed: 'Einige aktuelle Drucke konnten nicht archiviert werden — die Dateiübertragung hat zu lange gedauert',
+      bodyFtpTransferFailed: 'Die geslicete Datei lag auf der Karte des Druckers und der Drucker hat sie auch ausgeliefert, aber die Übertragung wurde nicht in der erlaubten Zeit fertig. Zum Druckstart bedient der Drucker gleichzeitig seine Kamera, seine Statusmeldungen und den Auftrags-Upload, und eine große 3MF kommt dagegen nicht immer durch. Diese Drucke sind weiterhin mit Name und Zeiten archiviert, nur ohne Vorschaubild und Slicer-Daten. Das ist keine Slicer-Einstellung und nichts, was Sie geändert haben. Bambuddy holt die Datei in den folgenden zehn Minuten noch dreimal nach, die meisten Fälle erledigen sich also von selbst; eine weiterhin leere Karte bedeutet, dass auch jeder dieser Versuche zu lange gedauert hat. Wenn es häufiger vorkommt, erhöhen Sie Verbindungs-Timeout unter Einstellungen > Netzwerk > FTP-Wiederholung.',
       dismissLabel: 'Hinweis schließen',
     },
     searchPlaceholder: 'Archiv durchsuchen...',

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

@@ -914,6 +914,8 @@ export default {
       bodyInternalHistory: 'Those prints ran from the printer\'s own library — a re-print from its screen, a start from Handy, or a file sent earlier and printed later. Bambuddy reads print files over FTP, which serves only the card or stick, while the printer keeps that library in an area FTP cannot reach, so there was no 3MF to read. No slicer setting changes this, because nothing was sent for these prints. They are still archived with their name and timing, and Edit Archive lets you fill in the filament used by hand. For a complete archive, start the print from Bambuddy or from your slicer instead.',
       titleFtpsCooloff: 'Some recent prints couldn\'t be archived — the printer refused the file connection',
       bodyFtpsCooloff: 'Bambuddy opened the printer\'s file-transfer port (FTPS 990) and the printer answered with something that is not TLS, so nothing could be read from it. Those prints are still archived with their name and timing, just without a thumbnail or slicer metadata. This is not a slicer setting and not something you changed — the same models and firmware run normally on other installs, and an affected printer usually works again later on its own. After such a failure Bambuddy pauses transfers to that printer for five minutes and comes back for the file once the pause clears, so a short episode fills itself in; a card still empty means the refusal outlasted the retry. What triggers it is not yet known. If it keeps happening, turn on debug logging on the System page and attach a support bundle to your report.',
+      titleFtpTransferFailed: 'Some recent prints couldn\'t be archived — the file transfer ran out of time',
+      bodyFtpTransferFailed: 'The sliced file was on the printer\'s card and the printer served it, but the transfer did not finish in the time allowed. At print start the printer is also handling its camera, its status messages and the job upload, and a large 3MF does not always get through against all that. Those prints are still archived with their name and timing, just without a thumbnail or slicer metadata. This is not a slicer setting and not something you changed. Bambuddy comes back for the file three times over the following ten minutes, so most of these fill themselves in; a card still empty means every attempt ran out of time too. If it keeps happening, raise Connection Timeout under Settings > Network > FTP Retry.',
     },
     searchPlaceholder: 'Search archives...',
     filterByPrinter: 'Filter by printer',

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

@@ -908,6 +908,8 @@ export default {
       bodyInternalHistory: 'Esas impresiones salieron de la propia biblioteca de la impresora: una reimpresión desde su pantalla, un inicio desde Handy o un archivo enviado antes e impreso después. Bambuddy lee los archivos de impresión por FTP, que solo sirve la tarjeta o la memoria, mientras que la impresora guarda esa biblioteca en una zona que FTP no alcanza, así que no había ningún 3MF que leer. Ninguna opción del laminador cambia esto, porque para estas impresiones no se envió nada. Se siguen archivando con su nombre y sus tiempos, y «Editar archivo» permite anotar a mano el filamento usado. Para un archivo completo, inicia la impresión desde Bambuddy o desde tu laminador.',
       titleFtpsCooloff: 'Algunas impresiones recientes no se pudieron archivar — la impresora rechazó la conexión de archivos',
       bodyFtpsCooloff: 'Bambuddy abrió el puerto de transferencia de archivos de la impresora (FTPS 990) y la impresora respondió con algo que no es TLS, así que no se pudo leer nada de ella. Esas impresiones siguen archivadas con su nombre y sus tiempos, solo que sin miniatura ni metadatos del laminador. No es un ajuste del laminador ni algo que usted haya cambiado — los mismos modelos y firmware funcionan con normalidad en otras instalaciones, y una impresora afectada suele volver a funcionar sola más tarde. Tras un fallo así, Bambuddy pausa las transferencias a esa impresora durante cinco minutos y vuelve a por el archivo cuando termina la pausa, de modo que un episodio breve se resuelve por sí solo; una ficha que sigue vacía significa que el rechazo duró más que el reintento. Todavía no se sabe qué lo provoca. Si se repite, active el registro de depuración en la página Sistema y adjunte un paquete de soporte a su informe.',
+      titleFtpTransferFailed: 'Algunas impresiones recientes no se pudieron archivar: la transferencia del archivo agotó su tiempo',
+      bodyFtpTransferFailed: 'El archivo laminado estaba en la tarjeta de la impresora y la impresora lo sirvió, pero la transferencia no terminó en el tiempo permitido. Al iniciar la impresión la impresora también atiende su cámara, sus mensajes de estado y la subida del trabajo, y un 3MF grande no siempre consigue pasar con todo eso. Esas impresiones siguen archivadas con su nombre y sus tiempos, solo que sin miniatura ni metadatos del laminador. No es un ajuste del laminador ni algo que usted haya cambiado. Bambuddy vuelve a por el archivo tres veces durante los diez minutos siguientes, así que la mayoría se completan solas; una tarjeta que sigue vacía significa que todos esos intentos también agotaron su tiempo. Si se repite, aumente Tiempo de espera de conexión en Ajustes > Red > Reintento FTP.',
       dismissLabel: 'Descartar este aviso',
     },
     searchPlaceholder: 'Buscar archivos...',

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

@@ -908,6 +908,8 @@ export default {
       bodyInternalHistory: 'Ces impressions sont parties de la bibliothèque de l\'imprimante elle-même : une réimpression depuis son écran, un lancement depuis Handy, ou un fichier envoyé plus tôt et imprimé ensuite. Bambuddy lit les fichiers d\'impression en FTP, qui ne dessert que la carte ou la clé, tandis que l\'imprimante conserve cette bibliothèque dans une zone que le FTP n\'atteint pas : il n\'y avait donc aucun 3MF à lire. Aucun réglage du trancheur n\'y change quoi que ce soit, puisque rien n\'a été envoyé pour ces impressions. Elles restent archivées avec leur nom et leurs durées, et « Modifier l\'archive » permet de saisir à la main le filament utilisé. Pour une archive complète, lancez plutôt l\'impression depuis Bambuddy ou depuis votre trancheur.',
       titleFtpsCooloff: 'Certaines impressions récentes n\'ont pas pu être archivées — l\'imprimante a refusé la connexion de fichiers',
       bodyFtpsCooloff: 'Bambuddy a ouvert le port de transfert de fichiers de l\'imprimante (FTPS 990) et celle-ci a répondu par autre chose que du TLS, si bien que rien n\'a pu en être lu. Ces impressions restent archivées avec leur nom et leurs durées, simplement sans vignette ni métadonnées du trancheur. Ce n\'est pas un réglage du trancheur ni quelque chose que vous avez modifié — les mêmes modèles et les mêmes firmwares fonctionnent normalement sur d\'autres installations, et une imprimante touchée refonctionne généralement d\'elle-même plus tard. Après un tel échec, Bambuddy suspend les transferts vers cette imprimante pendant cinq minutes puis revient chercher le fichier une fois la pause terminée : un épisode bref se répare donc tout seul, tandis qu\'une fiche encore vide signifie que le refus a duré plus longtemps que la nouvelle tentative. On ignore encore ce qui le déclenche. Si cela se reproduit, activez la journalisation de débogage sur la page Système et joignez un paquet de support à votre signalement.',
+      titleFtpTransferFailed: 'Certaines impressions récentes n\'ont pas pu être archivées : le transfert du fichier a dépassé le temps imparti',
+      bodyFtpTransferFailed: 'Le fichier tranché se trouvait sur la carte de l\'imprimante et l\'imprimante l\'a bien servi, mais le transfert ne s\'est pas terminé dans le temps imparti. Au démarrage d\'une impression, l\'imprimante gère aussi sa caméra, ses messages d\'état et l\'envoi du travail, et un gros 3MF ne passe pas toujours face à tout cela. Ces impressions restent archivées avec leur nom et leurs durées, simplement sans miniature ni métadonnées du trancheur. Ce n\'est pas un réglage du trancheur ni quelque chose que vous avez modifié. Bambuddy revient chercher le fichier trois fois au cours des dix minutes suivantes, la plupart de ces cas se règlent donc d\'eux-mêmes ; une carte toujours vide signifie que chacune de ces tentatives a également dépassé le temps imparti. Si cela se reproduit, augmentez Délai de connexion dans Paramètres > Réseau > Nouvelle tentative FTP.',
       dismissLabel: 'Ignorer ce message',
     },
     searchPlaceholder: 'Chercher dans les archives...',

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

@@ -908,6 +908,8 @@ export default {
       bodyInternalHistory: 'Quelle stampe sono uscite dalla libreria della stampante stessa: una ristampa dal suo schermo, un avvio da Handy o un file inviato prima e stampato dopo. Bambuddy legge i file di stampa via FTP, che serve solo la scheda o la chiavetta, mentre la stampante tiene quella libreria in un\'area che l\'FTP non raggiunge, quindi non c\'era alcun 3MF da leggere. Nessuna impostazione dello slicer cambia questo, perché per queste stampe non è stato inviato nulla. Restano archiviate con nome e tempi, e «Modifica archivio» consente di inserire a mano il filamento usato. Per un archivio completo, avvia la stampa da Bambuddy o dal tuo slicer.',
       titleFtpsCooloff: 'Alcune stampe recenti non sono state archiviate — la stampante ha rifiutato la connessione dei file',
       bodyFtpsCooloff: 'Bambuddy ha aperto la porta di trasferimento file della stampante (FTPS 990) e la stampante ha risposto con qualcosa che non è TLS, quindi non è stato possibile leggere nulla. Quelle stampe restano archiviate con nome e tempi, solo senza miniatura né metadati dello slicer. Non è un\'impostazione dello slicer né qualcosa che hai cambiato tu — gli stessi modelli e firmware funzionano normalmente su altre installazioni, e una stampante colpita di solito torna a funzionare da sola più tardi. Dopo un errore simile Bambuddy sospende i trasferimenti verso quella stampante per cinque minuti e ritorna a prendere il file al termine della pausa, così un episodio breve si risolve da sé; una scheda ancora vuota significa che il rifiuto è durato più del secondo tentativo. Non si sa ancora che cosa lo scateni. Se continua a capitare, attiva la registrazione di debug nella pagina Sistema e allega un pacchetto di supporto alla tua segnalazione.',
+      titleFtpTransferFailed: 'Alcune stampe recenti non sono state archiviate: il trasferimento del file ha esaurito il tempo',
+      bodyFtpTransferFailed: 'Il file elaborato era sulla scheda della stampante e la stampante lo ha effettivamente servito, ma il trasferimento non si è concluso nel tempo consentito. All\'avvio della stampa la stampante gestisce anche la telecamera, i messaggi di stato e il caricamento del lavoro, e un 3MF di grandi dimensioni non sempre riesce a passare. Quelle stampe restano archiviate con nome e tempi, solo senza miniatura né metadati dello slicer. Non è un\'impostazione dello slicer né qualcosa che hai cambiato. Bambuddy torna a prendere il file tre volte nei dieci minuti successivi, quindi la maggior parte dei casi si risolve da sola; una scheda ancora vuota significa che anche tutti quei tentativi hanno esaurito il tempo. Se continua a succedere, aumenta Timeout di connessione in Impostazioni > Rete > Nuovo tentativo FTP.',
       dismissLabel: 'Chiudi questo avviso',
     },
     searchPlaceholder: 'Cerca archivi...',

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

@@ -907,6 +907,8 @@ export default {
       bodyInternalHistory: 'これらの印刷はプリンター自身のライブラリから実行されました — 画面からの再印刷、Handy からの開始、または以前に送信して後から印刷したファイルです。Bambuddy は印刷ファイルを FTP で読み取りますが、FTP が扱えるのはカードまたは USB メモリだけで、プリンターはそのライブラリを FTP の届かない領域に保存するため、読み取れる 3MF がありませんでした。これらの印刷では何も送信されていないので、スライサーの設定を変えても解決しません。名前と時間付きでのアーカイブは続き、「アーカイブを編集」で使用フィラメントを手入力できます。完全なアーカイブにするには、Bambuddy またはスライサーから印刷を開始してください。',
       titleFtpsCooloff: '最近の一部の印刷を保存できませんでした — プリンターがファイル接続を拒否しました',
       bodyFtpsCooloff: 'Bambuddy がプリンターのファイル転送ポート (FTPS 990) を開いたところ、プリンターは TLS ではないもので応答したため、何も読み取れませんでした。これらの印刷は名前と時間付きでアーカイブされていますが、サムネイルとスライサーのメタデータはありません。これはスライサーの設定でも、お客様が変更したことでもありません。同じ機種・同じファームウェアが他の環境では正常に動作しており、影響を受けたプリンターも通常はしばらくすると自然に復帰します。この失敗のあと Bambuddy はそのプリンターへの転送を 5 分間停止し、停止が明けてからファイルを取りに戻ります。短時間の事象であれば自動的に埋まり、カードが空のままであれば拒否が再試行より長く続いたことを意味します。原因はまだ分かっていません。繰り返す場合は、システムページでデバッグログを有効にし、サポートバンドルを報告に添付してください。',
+      titleFtpTransferFailed: '最近の一部の印刷を保存できませんでした — ファイル転送が時間切れになりました',
+      bodyFtpTransferFailed: 'スライス済みファイルはプリンターのカードにあり、プリンターも実際に送り出していましたが、許容時間内に転送が完了しませんでした。印刷開始時のプリンターはカメラ、ステータス通知、ジョブのアップロードも同時に処理しているため、大きな 3MF はその中を通り切れないことがあります。これらの印刷は名前と時間つきで保存されていますが、サムネイルとスライサー情報がありません。これはスライサーの設定ではなく、お客様が変更した結果でもありません。Bambuddy はその後 10 分のあいだにファイルを 3 回取りに戻るため、多くはひとりでに埋まります。カードが空のままの場合は、そのすべての試行も時間切れになったということです。繰り返す場合は、設定 > ネットワーク > FTP リトライ の 接続タイムアウト を大きくしてください。',
       dismissLabel: 'この通知を閉じる',
     },
     searchPlaceholder: 'アーカイブを検索...',

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

@@ -864,6 +864,8 @@ export default {
       bodyInternalHistory: '해당 출력물은 프린터 자체 라이브러리에서 실행되었습니다 — 화면에서의 재출력, Handy에서의 시작, 또는 이전에 보내 두고 나중에 출력한 파일입니다. Bambuddy는 출력 파일을 FTP로 읽는데 FTP는 카드나 USB만 제공하고, 프린터는 그 라이브러리를 FTP가 닿지 않는 영역에 보관하므로 읽을 3MF가 없었습니다. 이 출력물들은 아무것도 전송되지 않았으므로 슬라이서 설정으로는 해결되지 않습니다. 이름과 시간과 함께 계속 보관되며, "아카이브 편집"에서 사용된 필라멘트를 직접 입력할 수 있습니다. 온전한 보관을 원하면 Bambuddy나 슬라이서에서 출력을 시작하세요.',
       titleFtpsCooloff: '최근 일부 출력물을 보관하지 못했습니다 — 프린터가 파일 연결을 거부했습니다',
       bodyFtpsCooloff: 'Bambuddy가 프린터의 파일 전송 포트(FTPS 990)를 열었으나 프린터가 TLS가 아닌 것으로 응답해 아무것도 읽을 수 없었습니다. 해당 출력물은 이름과 시간과 함께 보관되지만 미리보기와 슬라이서 메타데이터는 없습니다. 이는 슬라이서 설정 문제도, 사용자가 바꾼 것도 아닙니다. 같은 모델과 같은 펌웨어가 다른 설치 환경에서는 정상 동작하며, 문제가 생긴 프린터도 대개 나중에 저절로 다시 동작합니다. 이런 실패 후 Bambuddy는 해당 프린터로의 전송을 5분간 멈추고, 그 후 파일을 다시 가지러 갑니다. 짧은 문제라면 스스로 채워지고, 카드가 계속 비어 있다면 거부가 재시도보다 오래 지속되었다는 뜻입니다. 무엇이 원인인지는 아직 밝혀지지 않았습니다. 반복된다면 시스템 페이지에서 디버그 로깅을 켠 뒤 지원 번들을 보고에 첨부해 주세요.',
+      titleFtpTransferFailed: '최근 일부 출력을 보관하지 못했습니다 — 파일 전송이 시간 내에 끝나지 않았습니다',
+      bodyFtpTransferFailed: '슬라이싱된 파일은 프린터 카드에 있었고 프린터도 실제로 전송을 시작했지만, 허용된 시간 안에 전송이 끝나지 않았습니다. 출력 시작 시점의 프린터는 카메라와 상태 메시지, 작업 업로드까지 함께 처리하고 있어서 큰 3MF 파일은 그 사이를 통과하지 못할 때가 있습니다. 해당 출력은 이름과 시간과 함께 보관되어 있고, 썸네일과 슬라이서 정보만 없습니다. 슬라이서 설정 문제도 아니고 사용자가 바꾼 것 때문도 아닙니다. Bambuddy가 이후 10분 동안 파일을 세 번 더 가지러 가므로 대부분은 저절로 채워집니다. 카드가 계속 비어 있다면 그 시도들도 모두 시간 내에 끝나지 않았다는 뜻입니다. 계속 발생하면 설정 > 네트워크 > FTP 재시도 의 연결 제한 시간을 늘리세요.',
       dismissLabel: '이 알림 닫기'
     },
     searchPlaceholder: '아카이브 검색...',

+ 2 - 0
frontend/src/i18n/locales/nl.ts

@@ -914,6 +914,8 @@ export default {
       bodyInternalHistory: 'Die prints liepen vanuit de eigen bibliotheek van de printer — opnieuw afdrukken via het scherm, starten vanuit Handy, of een bestand dat eerder is verstuurd en later is afgedrukt. Bambuddy leest printbestanden via FTP, dat alleen de kaart of stick aanbiedt, terwijl de printer die bibliotheek bewaart op een plek die FTP niet kan bereiken. Er was dus geen 3MF om te lezen. Geen enkele slicer-instelling verandert dit, omdat er voor deze prints niets is verstuurd. Ze worden nog steeds gearchiveerd met naam en tijden, en via Archief bewerken kun je het gebruikte filament handmatig invullen. Start de print vanuit Bambuddy of vanuit je slicer voor een volledig archief.',
       titleFtpsCooloff: 'Sommige recente prints konden niet worden gearchiveerd — de printer weigerde de bestandsverbinding',
       bodyFtpsCooloff: 'Bambuddy opende de bestandsoverdrachtspoort van de printer (FTPS 990) en de printer antwoordde met iets dat geen TLS is, waardoor er niets van te lezen viel. Die prints staan nog wel in het archief met hun naam en tijden, alleen zonder miniatuur of slicer-metadata. Dit is geen slicer-instelling en niets wat u hebt gewijzigd — dezelfde modellen en firmware draaien normaal op andere installaties, en een getroffen printer werkt later meestal vanzelf weer. Na zo\'n fout pauzeert Bambuddy de overdrachten naar die printer vijf minuten en haalt het bestand daarna alsnog op, dus een korte episode herstelt zichzelf; een kaart die leeg blijft betekent dat de weigering langer duurde dan de herhaalpoging. Wat het veroorzaakt is nog niet bekend. Blijft het gebeuren, zet dan debug-logging aan op de pagina Systeem en voeg een supportpakket bij uw melding.',
+      titleFtpTransferFailed: 'Sommige recente prints konden niet worden gearchiveerd — de bestandsoverdracht duurde te lang',
+      bodyFtpTransferFailed: 'Het gesliced bestand stond op de kaart van de printer en de printer leverde het ook aan, maar de overdracht was niet binnen de toegestane tijd klaar. Bij de start van een print bedient de printer ook zijn camera, zijn statusberichten en de upload van de opdracht, en een grote 3MF komt daar niet altijd doorheen. Die prints zijn nog steeds gearchiveerd met naam en tijden, alleen zonder miniatuur of slicergegevens. Dit is geen slicerinstelling en niets dat u hebt gewijzigd. Bambuddy haalt het bestand in de tien minuten daarna nog drie keer op, dus de meeste gevallen lossen zichzelf op; een kaart die leeg blijft betekent dat ook al die pogingen te lang duurden. Als het vaker gebeurt, verhoog dan Verbindingstime-out onder Instellingen > Netwerk > FTP opnieuw proberen.',
     },
     searchPlaceholder: 'Archieven zoeken...',
     filterByPrinter: 'Filteren op printer',

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

@@ -908,6 +908,8 @@ export default {
       bodyInternalHistory: 'Essas impressões saíram da própria biblioteca da impressora: uma reimpressão pela tela dela, um início pelo Handy ou um arquivo enviado antes e impresso depois. O Bambuddy lê os arquivos de impressão por FTP, que só serve o cartão ou o pendrive, enquanto a impressora guarda essa biblioteca em uma área que o FTP não alcança, então não havia nenhum 3MF para ler. Nenhuma opção do fatiador muda isso, porque nada foi enviado para essas impressões. Elas continuam arquivadas com nome e tempos, e «Editar Arquivo» permite preencher à mão o filamento usado. Para um arquivo completo, inicie a impressão pelo Bambuddy ou pelo seu fatiador.',
       titleFtpsCooloff: 'Algumas impressões recentes não puderam ser arquivadas — a impressora recusou a conexão de arquivos',
       bodyFtpsCooloff: 'O Bambuddy abriu a porta de transferência de arquivos da impressora (FTPS 990) e a impressora respondeu com algo que não é TLS, então nada pôde ser lido dela. Essas impressões continuam arquivadas com nome e tempos, apenas sem miniatura nem metadados do fatiador. Não é uma configuração do fatiador nem algo que você mudou — os mesmos modelos e firmwares funcionam normalmente em outras instalações, e uma impressora afetada costuma voltar a funcionar sozinha depois. Após uma falha dessas, o Bambuddy pausa as transferências para essa impressora por cinco minutos e volta a buscar o arquivo quando a pausa termina, de modo que um episódio curto se resolve sozinho; um cartão ainda vazio significa que a recusa durou mais que a nova tentativa. Ainda não se sabe o que provoca isso. Se continuar acontecendo, ative o registro de depuração na página Sistema e anexe um pacote de suporte ao seu relato.',
+      titleFtpTransferFailed: 'Algumas impressões recentes não puderam ser arquivadas — a transferência do arquivo esgotou o tempo',
+      bodyFtpTransferFailed: 'O arquivo fatiado estava no cartão da impressora e a impressora chegou a servi-lo, mas a transferência não terminou no tempo permitido. No início da impressão a impressora também cuida da câmera, das mensagens de status e do envio do trabalho, e um 3MF grande nem sempre consegue passar no meio disso. Essas impressões continuam arquivadas com nome e tempos, apenas sem miniatura nem metadados do fatiador. Não é uma configuração do fatiador nem algo que você mudou. O Bambuddy volta a buscar o arquivo três vezes ao longo dos dez minutos seguintes, então a maioria se resolve sozinha; um cartão ainda vazio significa que todas essas tentativas também esgotaram o tempo. Se continuar acontecendo, aumente Tempo limite de conexão em Configurações > Rede > Nova tentativa de FTP.',
       dismissLabel: 'Dispensar este aviso',
     },
     searchPlaceholder: 'Pesquisar arquivos...',

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

@@ -863,6 +863,8 @@ export default {
       bodyInternalHistory: 'Эти печати шли из собственной библиотеки принтера — повторная печать с его экрана, запуск из Handy или файл, отправленный раньше и напечатанный позже. Bambuddy читает файлы печати по FTP, а он отдаёт только карту или флешку, тогда как принтер держит эту библиотеку в области, куда FTP не достаёт, — читать 3MF было негде. Настройки слайсера тут ничего не меняют: для этих печатей ничего не отправлялось. Они по-прежнему архивируются с именем и временем, а в «Редактировании архива» израсходованный филамент можно указать вручную. Чтобы архив был полным, запускайте печать из Bambuddy или из своего слайсера.',
       titleFtpsCooloff: 'Некоторые недавние печати не удалось заархивировать — принтер отклонил файловое соединение',
       bodyFtpsCooloff: 'Bambuddy открыл порт передачи файлов принтера (FTPS 990), и принтер ответил чем-то, что не является TLS, поэтому прочитать с него ничего не удалось. Эти печати всё равно сохранены в архиве с названием и временем, только без миниатюры и метаданных слайсера. Это не настройка слайсера и не то, что вы меняли: те же модели и прошивки нормально работают на других установках, а затронутый принтер обычно позже начинает работать сам. После такой ошибки Bambuddy приостанавливает передачи к этому принтеру на пять минут и возвращается за файлом, когда пауза заканчивается, поэтому короткий эпизод исправляется сам; если карточка так и осталась пустой, значит отказ продлился дольше повторной попытки. Что именно это вызывает, пока неизвестно. Если повторяется, включите отладочное журналирование на странице Система и приложите пакет поддержки к своему сообщению.',
+      titleFtpTransferFailed: 'Некоторые недавние печати не удалось заархивировать — передача файла не уложилась во время',
+      bodyFtpTransferFailed: 'Нарезанный файл лежал на карте принтера, и принтер его действительно отдавал, но передача не завершилась за отведённое время. В момент старта печати принтер одновременно обслуживает камеру, сообщения о состоянии и загрузку задания, и крупный 3MF не всегда успевает пройти на этом фоне. Эти печати по-прежнему заархивированы с названием и временем, только без миниатюры и данных слайсера. Это не настройка слайсера и не следствие ваших изменений. Bambuddy возвращается за файлом ещё три раза в течение следующих десяти минут, поэтому большинство таких случаев закрываются сами; если карточка так и осталась пустой, значит и все эти попытки не уложились во время. Если это повторяется, увеличьте Таймаут подключения в разделе Настройки > Сеть > Повтор FTP.',
       dismissLabel: "Закрыть это уведомление",
     },
     searchPlaceholder: "Поиск в архиве...",

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

@@ -908,6 +908,8 @@ export default {
       bodyInternalHistory: 'Bu baskılar yazıcının kendi kitaplığından çalıştı — ekranından yeniden baskı, Handy üzerinden başlatma ya da daha önce gönderilip sonra basılan bir dosya. Bambuddy baskı dosyalarını FTP üzerinden okur, FTP ise yalnızca kartı veya belleği sunar; yazıcı bu kitaplığı FTP\'nin ulaşamadığı bir alanda tutar, dolayısıyla okunacak bir 3MF yoktu. Hiçbir dilimleyici ayarı bunu değiştirmez, çünkü bu baskılar için hiçbir şey gönderilmedi. Adları ve süreleriyle arşivlenmeye devam ederler ve "Arşivi Düzenle" ile kullanılan filamenti elle girebilirsiniz. Eksiksiz bir arşiv için baskıyı Bambuddy\'den ya da dilimleyicinizden başlatın.',
       titleFtpsCooloff: 'Bazı son baskılar arşivlenemedi — yazıcı dosya bağlantısını reddetti',
       bodyFtpsCooloff: 'Bambuddy yazıcının dosya aktarım portunu (FTPS 990) açtı ve yazıcı TLS olmayan bir şeyle yanıt verdi, bu yüzden ondan hiçbir şey okunamadı. O baskılar adları ve süreleriyle yine arşivlenir, yalnızca küçük resim ve dilimleyici meta verileri olmadan. Bu bir dilimleyici ayarı değil ve sizin değiştirdiğiniz bir şey de değil — aynı modeller ve aynı ürün yazılımları başka kurulumlarda sorunsuz çalışıyor ve etkilenen bir yazıcı genellikle bir süre sonra kendiliğinden yeniden çalışıyor. Böyle bir hatadan sonra Bambuddy o yazıcıya yapılan aktarımları beş dakika duraklatır ve duraklama bitince dosyayı yeniden almaya gelir; kısa bir kesinti böylece kendiliğinden düzelir, hâlâ boş duran bir kart ise reddin yeniden denemeden daha uzun sürdüğü anlamına gelir. Buna neyin yol açtığı henüz bilinmiyor. Tekrarlıyorsa Sistem sayfasından hata ayıklama günlüğünü açın ve bildiriminize bir destek paketi ekleyin.',
+      titleFtpTransferFailed: 'Bazı son baskılar arşivlenemedi — dosya aktarımı süreye sığmadı',
+      bodyFtpTransferFailed: 'Dilimlenmiş dosya yazıcının kartındaydı ve yazıcı dosyayı gerçekten sunuyordu, ancak aktarım izin verilen sürede tamamlanmadı. Baskı başlarken yazıcı aynı anda kamerasıyla, durum mesajlarıyla ve iş yüklemesiyle de ilgilenir; büyük bir 3MF bunların arasından her zaman geçemez. Bu baskılar adı ve süreleriyle hâlâ arşivde, yalnızca küçük resim ve dilimleyici verileri eksik. Bu bir dilimleyici ayarı değil ve sizin değiştirdiğiniz bir şeyden kaynaklanmıyor. Bambuddy sonraki on dakika içinde dosyayı üç kez daha almaya gider, bu yüzden çoğu kendiliğinden tamamlanır; hâlâ boş duran bir kart, o denemelerin de süreye sığmadığı anlamına gelir. Sık sık oluyorsa Ayarlar > Ağ > FTP Yeniden Deneme altındaki Bağlantı Zaman Aşımı değerini artırın.',
       dismissLabel: 'Bu bildirimi kapat',
     },
     searchPlaceholder: 'Arşivlerde ara...',

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

@@ -912,6 +912,8 @@ export default {
       bodyInternalHistory: 'Ці друки йшли з власної бібліотеки принтера — повторний друк з його екрана, запуск із Handy або файл, надісланий раніше й надрукований пізніше. Bambuddy читає файли друку через FTP, а той віддає лише картку чи флешку, тоді як принтер тримає цю бібліотеку в області, куди FTP не дістає, — читати 3MF не було де. Налаштування слайсера тут нічого не змінюють: для цих друків нічого не надсилалося. Вони й далі архівуються з назвою та часом, а в «Редагувати архів» витрачений філамент можна вписати вручну. Щоб архів був повним, запускайте друк із Bambuddy або зі свого слайсера.',
       titleFtpsCooloff: 'Деякі нещодавні друки не вдалося заархівувати — принтер відхилив файлове з\'єднання',
       bodyFtpsCooloff: 'Bambuddy відкрив порт передавання файлів принтера (FTPS 990), і принтер відповів чимось, що не є TLS, тож прочитати з нього нічого не вдалося. Ці друки все одно збережені в архіві з назвою та часом, лише без мініатюри й метаданих слайсера. Це не налаштування слайсера і не те, що ви змінювали: ті самі моделі та прошивки нормально працюють на інших встановленнях, а уражений принтер зазвичай згодом починає працювати сам. Після такої помилки Bambuddy призупиняє передавання до цього принтера на п\'ять хвилин і повертається по файл, коли пауза завершується, тож короткий епізод виправляється сам; якщо картка й далі порожня, відмова тривала довше за повторну спробу. Що саме це спричиняє, поки невідомо. Якщо повторюється, увімкніть налагоджувальне журналювання на сторінці Система та додайте пакет підтримки до свого звіту.',
+      titleFtpTransferFailed: 'Деякі нещодавні друки не вдалося заархівувати — передавання файлу не вклалося в час',
+      bodyFtpTransferFailed: 'Нарізаний файл лежав на картці принтера, і принтер справді його віддавав, але передавання не завершилося у відведений час. На старті друку принтер одночасно обслуговує камеру, повідомлення про стан і завантаження завдання, і великий 3MF не завжди встигає пройти на цьому тлі. Ці друки й далі заархівовані з назвою та часом, лише без мініатюри та даних слайсера. Це не налаштування слайсера і не наслідок ваших змін. Bambuddy повертається по файл ще тричі протягом наступних десяти хвилин, тож більшість таких випадків закриваються самі; якщо картка так і лишилася порожньою, то й усі ці спроби не вклалися в час. Якщо це повторюється, збільште Тайм-аут підключення в розділі Налаштування > Мережа > Повтор FTP.',
       dismissLabel: "Відхилити це повідомлення",
     },
     searchPlaceholder: "Пошук в архівах...",

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

@@ -908,6 +908,8 @@ export default {
       bodyInternalHistory: '这些打印来自打印机自己的文件库 — 从它的屏幕重新打印、从 Handy 启动,或是先前发送、稍后才打印的文件。Bambuddy 通过 FTP 读取打印文件,而 FTP 只提供存储卡或 U 盘,打印机却把这个文件库放在 FTP 够不到的区域,所以没有 3MF 可读。切片软件的任何设置都改变不了这一点,因为这些打印根本没有发送过文件。它们仍会带着名称和时间归档,并且可以在「编辑归档」里手动填写已用耗材。要获得完整归档,请从 Bambuddy 或你的切片软件启动打印。',
       titleFtpsCooloff: '最近有些打印无法归档 — 打印机拒绝了文件连接',
       bodyFtpsCooloff: 'Bambuddy 打开了打印机的文件传输端口 (FTPS 990),而打印机回应的内容并不是 TLS,因此无法从它读取任何东西。这些打印仍会带着名称和时间归档,只是没有缩略图和切片元数据。这不是切片软件的设置,也不是你改动了什么 —— 相同型号、相同固件在别的安装上运行正常,受影响的打印机通常过一阵子会自行恢复。出现这种失败后,Bambuddy 会暂停对该打印机的传输五分钟,暂停结束后再回来取文件,所以短暂的一次会自行补全;卡片仍然是空的,说明拒绝持续得比重试更久。触发原因目前尚不清楚。如果反复出现,请在系统页面打开调试日志,并把支持包附在你的报告里。',
+      titleFtpTransferFailed: '部分近期打印未能归档 — 文件传输超时',
+      bodyFtpTransferFailed: '切片文件就在打印机的存储卡上,打印机也确实开始传输了,但传输未能在允许的时间内完成。打印开始时,打印机还要同时处理摄像头、状态消息和作业上传,较大的 3MF 文件未必能在这种情况下传完。这些打印仍然保留了名称和时间,只是缺少缩略图和切片数据。这不是切片软件的设置问题,也不是你改动造成的。Bambuddy 会在接下来的十分钟内再取三次文件,因此多数情况会自行补齐;如果卡片仍然是空的,说明那几次尝试同样超时了。若经常出现,请在 设置 > 网络 > FTP 重试 中调高 连接超时。',
       dismissLabel: '关闭此通知',
     },
     searchPlaceholder: '搜索归档...',

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

@@ -908,6 +908,8 @@ export default {
       bodyInternalHistory: '這些列印來自印表機自己的檔案庫 — 從它的螢幕重新列印、從 Handy 啟動,或是先前傳送、稍後才列印的檔案。Bambuddy 透過 FTP 讀取列印檔案,而 FTP 只提供記憶卡或隨身碟,印表機卻把這個檔案庫放在 FTP 搆不到的區域,因此沒有 3MF 可讀。切片軟體的任何設定都改變不了這一點,因為這些列印根本沒有傳送過檔案。它們仍會帶著名稱與時間歸檔,並且可以在「編輯歸檔」中手動填入已用耗材。要獲得完整歸檔,請從 Bambuddy 或你的切片軟體啟動列印。',
       titleFtpsCooloff: '最近有些列印無法歸檔 — 印表機拒絕了檔案連線',
       bodyFtpsCooloff: 'Bambuddy 開啟了印表機的檔案傳輸連接埠 (FTPS 990),而印表機回應的內容並不是 TLS,因此無法從它讀取任何東西。這些列印仍會帶著名稱和時間歸檔,只是沒有縮圖和切片中繼資料。這不是切片軟體的設定,也不是你改動了什麼 —— 相同型號、相同韌體在其他安裝上運作正常,受影響的印表機通常過一陣子會自行恢復。出現這種失敗後,Bambuddy 會暫停對該印表機的傳輸五分鐘,暫停結束後再回來取檔案,所以短暫的一次會自行補齊;卡片仍然是空的,表示拒絕持續得比重試更久。觸發原因目前尚不清楚。如果反覆出現,請在系統頁面開啟除錯記錄,並把支援套件附在你的報告裡。',
+      titleFtpTransferFailed: '部分近期列印未能封存 — 檔案傳輸逾時',
+      bodyFtpTransferFailed: '切片檔就在印表機的記憶卡上,印表機也確實開始傳輸了,但傳輸未能在允許的時間內完成。列印開始時,印表機還要同時處理攝影機、狀態訊息與工作上傳,較大的 3MF 檔未必能在這種情況下傳完。這些列印仍保留名稱與時間,只是缺少縮圖與切片資料。這不是切片軟體的設定問題,也不是你更動造成的。Bambuddy 會在接下來的十分鐘內再取三次檔案,因此多數情況會自行補齊;如果卡片仍是空的,代表那幾次嘗試同樣逾時了。若經常發生,請在 設定 > 網路 > FTP 重試 中調高 連線逾時。',
       dismissLabel: '關閉此通知',
     },
     searchPlaceholder: '搜尋歸檔...',

+ 26 - 19
frontend/src/pages/ArchivesPage.tsx

@@ -2867,35 +2867,41 @@ export function ArchivesPage() {
     setNo3MFWarningDismissed(true);
   };
   // Why the 3MF was missing decides what to tell the user, and the original
-  // single wording is wrong for four of the five cases: it sends H2-series and
+  // single wording is wrong for five of the six cases: it sends H2-series and
   // P2S owners to switch on a setting that is already on and would not have
   // helped, it blames the slicer when the real answer is an empty card slot
   // (#2780), it blames a slicer that was never involved when the print was
-  // started from a file already on the printer (#1820), and it blames the
+  // started from a file already on the printer (#1820), it blames the
   // slicer again when the printer's own file service refused the TLS handshake
-  // and no lookup was ever attempted (#2957, surfaced by #2780). An
-  // unknown/absent reason keeps the original text.
+  // and no lookup was ever attempted (#2957, surfaced by #2780), and it blames
+  // it a third time when the file was on the card, was served, and the transfer
+  // simply ran out of time (#3063). An unknown/absent reason keeps the original
+  // text.
   const no3MFVariant =
     no3MFWarning?.reason === 'ftps_cooloff'
       ? 'FtpsCooloff'
-      : no3MFWarning?.reason === 'internal_storage'
-        ? 'InternalStorage'
-        : no3MFWarning?.reason === 'no_external_storage'
-          ? 'NoExternalStorage'
-          : no3MFWarning?.reason === 'internal_history'
-            ? 'InternalHistory'
-            : '';
+      : no3MFWarning?.reason === 'ftp_transfer_failed'
+        ? 'FtpTransferFailed'
+        : no3MFWarning?.reason === 'internal_storage'
+          ? 'InternalStorage'
+          : no3MFWarning?.reason === 'no_external_storage'
+            ? 'NoExternalStorage'
+            : no3MFWarning?.reason === 'internal_history'
+              ? 'InternalHistory'
+              : '';
   // Nothing to link for the empty-slot case — "put a card in" is the whole fix.
   const no3MFDocsHref =
     no3MFWarning?.reason === 'ftps_cooloff'
       ? 'https://wiki.bambuddy.cool/reference/troubleshooting/#ftps-tls-failure'
-      : no3MFWarning?.reason === 'internal_storage'
-        ? 'https://wiki.bambuddy.cool/reference/troubleshooting/#archive-card-has-only-a-name'
-        : no3MFWarning?.reason === 'no_external_storage'
-          ? null
-          : no3MFWarning?.reason === 'internal_history'
-            ? 'https://wiki.bambuddy.cool/reference/troubleshooting/#print-started-on-the-printer-has-no-thumbnail'
-            : 'https://wiki.bambuddy.cool/getting-started/#step-4-enable-store-sent-files-on-external-storage';
+      : no3MFWarning?.reason === 'ftp_transfer_failed'
+        ? 'https://wiki.bambuddy.cool/reference/troubleshooting/#ftp-transfer-timed-out'
+        : no3MFWarning?.reason === 'internal_storage'
+          ? 'https://wiki.bambuddy.cool/reference/troubleshooting/#archive-card-has-only-a-name'
+          : no3MFWarning?.reason === 'no_external_storage'
+            ? null
+            : no3MFWarning?.reason === 'internal_history'
+              ? 'https://wiki.bambuddy.cool/reference/troubleshooting/#print-started-on-the-printer-has-no-thumbnail'
+              : '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);
@@ -3727,7 +3733,8 @@ export function ArchivesPage() {
                     {t(
                       no3MFWarning?.reason === 'internal_storage' ||
                         no3MFWarning?.reason === 'internal_history' ||
-                        no3MFWarning?.reason === 'ftps_cooloff'
+                        no3MFWarning?.reason === 'ftps_cooloff' ||
+                        no3MFWarning?.reason === 'ftp_transfer_failed'
                         ? 'archives.no3mfBanner.docsLinkInternalStorage'
                         : 'archives.no3mfBanner.docsLink',
                     )}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CBjd7PRB.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-CI9aSCio.js"></script>
+    <script type="module" crossorigin src="/assets/index-CBjd7PRB.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
   </head>
   <body>

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