Преглед изворни кода

Stop retrying a printer whose FTPS handshake fails, and name the cause (#2780)

Two printers went on printing while every archive they produced held nothing
but a filename. Bambuddy opened port 990, the printer accepted the connection
and answered with something that was not TLS, and connect() logged a warning
and returned False -- indistinguishable, to every caller, from "the file is
not at this path". So the 3MF lookup walked all six filename variants across
five directories with four retries each, the cover endpoint ran its own
sixteen-path sweep, and the timelapse scan added four more, all against a
sixteen-path sweep, and the timelapse scan added four more, all against a
printer that could not have answered any of them. One reporter's log carried
1813 identical handshake failures, another's 3511.

The evidence says this is the printer's own file service getting stuck, not a
model, firmware or TLS-configuration problem. In #2780's bundle the same two
printers ran clean from 22 July to 4 August and failed again from the 5th; a
second bundle shows an X2D serving files for five days, flipping on 19 July,
then failing every connection for eight days with zero successes. The same
models and firmware appear in roughly twenty other bundles with no occurrences
at all. Both bundles show it happening with cap_tls_v1_2 in effect -- the X2D
and H2C entries in ftp_profiles were added on analogy with P2S to fix exactly
this symptom, and the reporter's own debug line proves they do not.

An ssl.SSLError from connect() now opens a five-minute cool-off for that
printer. Subsequent connects return False without touching the network, so a
wedged printer is contacted twice an hour instead of hundreds of times a
minute, and the single warning that is logged names the remedy. The cool-off
is dropped on expiry rather than kept, so the map holds one key per currently
wedged printer. ftps_handshake_blocked() lets the sweeps stop: the 3MF lookup
abandons the remaining paths and skips the directory-walk fallback, the cover
endpoint returns 503 naming the file service instead of a 404 that reads as
"this print has no thumbnail", and the timelapse scan separates 503 (cannot
reach the printer) from 404 (no timelapse directory) -- one 500 used to cover
both, which is what the reporter hit when reproducing.

The Connection Diagnostic completed a bare TCP connect to 990, which is why it
reported the port green throughout: the port is open, it is what is behind it
that is broken. It now completes a real implicit-TLS handshake using the
model's own ftp_profiles cap, so a pass means the FTP client would also get
through. An open port that cannot negotiate reports warn with reason no_tls,
selecting a new message in all 13 locales that points at a printer restart
rather than at the firewall. No login is attempted, so this stays valid in the
pre-save Add Printer flow.

The cool-off tests run against a real socket that accepts on 990 and replies
with a plaintext FTP banner, reproducing WRONG_VERSION_NUMBER rather than
mocking ssl. The autouse fixture clearing _mode_cache now clears the cool-off
map too -- every test here talks to 127.0.0.1, so one left behind would make
the next test's connect() a no-op.
maziggy пре 4 недеља
родитељ
комит
91acac2b35

+ 1 - 0
CHANGELOG.md

@@ -19,6 +19,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests.
 - **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests.
 
 
 ### Fixed
 ### Fixed
+- **A printer whose file service stops answering is named as such instead of quietly emptying your archives (#2780, reported by @Utility9298 and @AntonPalmqvist)** — Two printers went on printing normally while every archive they produced arrived holding nothing but a filename: no filament totals, no layer count, no cover image, no timelapse. The Connection Diagnostic reported the file-transfer port as reachable, because it was — the printer accepted the connection and then answered it with something that was not TLS at all, and the sliced file could never be read back. Bambuddy said nothing about that on screen; it retried. Because each candidate location opened its own connection, one reporter's log carried 1813 identical handshake failures and another's 3511, against a printer that could not have answered any of them. This is not a model or a firmware problem — the same printers worked for days before and after the fault, and other installs run the same models untouched. It is the printer's own file service getting stuck, and a power-cycle clears it. Bambuddy now stops after the first failed handshake and leaves that printer alone for five minutes, so the log carries a handful of entries that say what went wrong and what to do about it rather than thousands that say neither. The Connection Diagnostic now completes a real handshake instead of merely opening the port, so a printer in this state reads as a warning that names a restart — not as a green tick. Scanning for a timelapse on such a printer reports the file service, where it used to return one error message that covered both "the printer is unreachable" and "this printer has no timelapse folder", and asking for a cover image says the same thing instead of the "no cover for this print" it used to claim. Printing is unaffected throughout: the control connection is a separate service, which is exactly why the fault was invisible.
 - **Prints queued from a Slicer Pipeline or the Library are checked for enough filament again (#2779, reported by @wylyn3d)** — A job needing 20.5 g was dispatched onto a spool holding 9 g, and the printer started. The same file, printed from the Print dialog, was correctly refused. The check that stands between the queue and the printer reads the sliced file to learn how much each slot needs, and it looked for that file in the wrong place: a file in the Library records where it lives relative to Bambuddy's data directory, and this one check read that as a path from wherever the process happened to be running. It found nothing, and a source it cannot find has always meant "nothing to verify" rather than "stop" — so the job passed a check that never actually ran. Every path that queues a Library file was affected: Slicer Pipeline jobs, which are always Library-backed, and anything added through the Library's **Add to queue**. Both the automatic dispatcher and the Play button on the queue were equally blind, so the deficit could not be caught by starting the job by hand either. Prints queued from print history were never affected, and neither was the Print dialog, which finds the file its own way. Two things changed: the check now resolves a Library file the same way the eleven other places that read one already did, and a source file that is configured but missing now writes a warning to the log naming the item and the path it looked at. That case still dispatches — the upload needs the same file moments later and fails there, where blocking would strand a queue on a file the user may have moved — but it no longer passes in silence, which is what let this go unnoticed. Covered by backend tests, including the reporter's exact 20.5 g against 9 g.
 - **Prints queued from a Slicer Pipeline or the Library are checked for enough filament again (#2779, reported by @wylyn3d)** — A job needing 20.5 g was dispatched onto a spool holding 9 g, and the printer started. The same file, printed from the Print dialog, was correctly refused. The check that stands between the queue and the printer reads the sliced file to learn how much each slot needs, and it looked for that file in the wrong place: a file in the Library records where it lives relative to Bambuddy's data directory, and this one check read that as a path from wherever the process happened to be running. It found nothing, and a source it cannot find has always meant "nothing to verify" rather than "stop" — so the job passed a check that never actually ran. Every path that queues a Library file was affected: Slicer Pipeline jobs, which are always Library-backed, and anything added through the Library's **Add to queue**. Both the automatic dispatcher and the Play button on the queue were equally blind, so the deficit could not be caught by starting the job by hand either. Prints queued from print history were never affected, and neither was the Print dialog, which finds the file its own way. Two things changed: the check now resolves a Library file the same way the eleven other places that read one already did, and a source file that is configured but missing now writes a warning to the log naming the item and the path it looked at. That case still dispatches — the upload needs the same file moments later and fails there, where blocking would strand a queue on a file the user may have moved — but it no longer passes in silence, which is what let this go unnoticed. Covered by backend tests, including the reporter's exact 20.5 g against 9 g.
 - **A Forgejo token limited to a single repository can now be used for backups (#2775, reported by @AnthonyGrondin)** — Forgejo v15 lets you mint an access token that only reaches one repository, which is the safest token you can give a backup: leak it and the damage stops at the repository it was made for. Bambuddy refused it. **Test connection** asked Forgejo who the token belonged to before it asked whether the token could reach the repository, and a repository-scoped token is not allowed to answer that question — it may only carry read and write on issues and repositories — so the check failed on a token that would have backed up perfectly well. The identity question is now asked but no longer decides: only an outright rejection of the token is conclusive, and everything else falls through to the repository check, which is the one that matters. Nothing else in a backup ever needed the wider permission — the push writes through the repository's own contents endpoint and a restore reads its commits, trees and blobs — so ordinary tokens are unaffected. The message shown when the repository cannot be reached now names the scope to look for and the possibility that the token is scoped to a different repository, rather than only explaining Forgejo's habit of reporting a private repository as missing. The hint under the token field is also per provider now: it read "fine-grained token with Contents read/write" for all four, advice that only ever applied to GitHub, and now names GitHub's, GitLab's, Gitea's and Forgejo's own scopes in every language Bambuddy speaks. Covered by backend and frontend tests.
 - **A Forgejo token limited to a single repository can now be used for backups (#2775, reported by @AnthonyGrondin)** — Forgejo v15 lets you mint an access token that only reaches one repository, which is the safest token you can give a backup: leak it and the damage stops at the repository it was made for. Bambuddy refused it. **Test connection** asked Forgejo who the token belonged to before it asked whether the token could reach the repository, and a repository-scoped token is not allowed to answer that question — it may only carry read and write on issues and repositories — so the check failed on a token that would have backed up perfectly well. The identity question is now asked but no longer decides: only an outright rejection of the token is conclusive, and everything else falls through to the repository check, which is the one that matters. Nothing else in a backup ever needed the wider permission — the push writes through the repository's own contents endpoint and a restore reads its commits, trees and blobs — so ordinary tokens are unaffected. The message shown when the repository cannot be reached now names the scope to look for and the possibility that the token is scoped to a different repository, rather than only explaining Forgejo's habit of reporting a private repository as missing. The hint under the token field is also per provider now: it read "fine-grained token with Contents read/write" for all four, advice that only ever applied to GitHub, and now names GitHub's, GitLab's, Gitea's and Forgejo's own scopes in every language Bambuddy speaks. Covered by backend and frontend tests.
 - **Files queued from the Library are no longer missing from their owner's queue** — On an installation with authentication turned on, a user whose permissions are scoped to their own work saw an empty queue after adding files from the Library, and adding more only added more nothing. The jobs were really there and really printed; they simply belonged to no one. Every queue item records who created it, and the "own queue" permissions decide what to show by comparing that against the signed-in user — but the Library's bulk **Add to queue** never wrote it down, so its items matched no one and were visible only to users who can see the whole queue. This affected the one path built for adding many files at once, which is where it was hardest to notice something was wrong: the file list on screen looked no different afterwards. The same omission applied to the queue endpoint of the webhook API, whose items are now credited to the owner of the API key that added them. Items that genuinely have no one behind them are unchanged and still belong to no one — jobs sent through a virtual printer, anything added while authentication is off, and keys created before API keys had owners. Covered by backend tests.
 - **Files queued from the Library are no longer missing from their owner's queue** — On an installation with authentication turned on, a user whose permissions are scoped to their own work saw an empty queue after adding files from the Library, and adding more only added more nothing. The jobs were really there and really printed; they simply belonged to no one. Every queue item records who created it, and the "own queue" permissions decide what to show by comparing that against the signed-in user — but the Library's bulk **Add to queue** never wrote it down, so its items matched no one and were visible only to users who can see the whole queue. This affected the one path built for adding many files at once, which is where it was hardest to notice something was wrong: the file list on screen looked no different afterwards. The same omission applied to the queue endpoint of the webhook API, whose items are now credited to the owner of the API key that added them. Items that genuinely have no one behind them are unchanged and still belong to no one — jobs sent through a virtual printer, anything added while authentication is off, and keys created before API keys had owners. Covered by backend tests.

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

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

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

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

+ 16 - 3
backend/app/main.py

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -6608,6 +6608,7 @@ export default {
         title: 'Dateiübertragungsport (FTPS 990)',
         title: 'Dateiübertragungsport (FTPS 990)',
         pass: 'Erreichbar — das Senden von Druckdateien funktioniert.',
         pass: 'Erreichbar — das Senden von Druckdateien funktioniert.',
         warn: 'Port 990 ist nicht erreichbar. Die Überwachung funktioniert möglicherweise weiterhin, aber das Senden von Drucken an den Drucker schlägt fehl. Stellen Sie sicher, dass Port 990 nicht blockiert ist.',
         warn: 'Port 990 ist nicht erreichbar. Die Überwachung funktioniert möglicherweise weiterhin, aber das Senden von Drucken an den Drucker schlägt fehl. Stellen Sie sicher, dass Port 990 nicht blockiert ist.',
+        warn_no_tls: 'Port 990 ist offen, aber der Dateidienst des Druckers schließt den TLS-Handshake nicht ab. Druckdateien, Vorschaubilder und Zeitraffer können nicht abgerufen werden, daher bleiben Archive leer. Starten Sie den Drucker neu — den Port freizugeben hilft hier nicht.',
       },
       },
       external_storage: {
       external_storage: {
         title: 'Gesendete Dateien auf externem Speicher speichern (Installationsschritt 4)',
         title: 'Gesendete Dateien auf externem Speicher speichern (Installationsschritt 4)',

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

@@ -6657,6 +6657,7 @@ export default {
         title: 'File transfer port (FTPS 990)',
         title: 'File transfer port (FTPS 990)',
         pass: 'Reachable — sending print files will work.',
         pass: 'Reachable — sending print files will work.',
         warn: 'Port 990 is unreachable. Monitoring may still work, but sending prints to the printer will fail. Make sure port 990 is not blocked.',
         warn: 'Port 990 is unreachable. Monitoring may still work, but sending prints to the printer will fail. Make sure port 990 is not blocked.',
+        warn_no_tls: 'Port 990 is open but the printer\'s file service is not completing a TLS handshake. Print files, covers and timelapses cannot be fetched, so archives stay empty. Restart the printer — unblocking the port will not help.',
       },
       },
       external_storage: {
       external_storage: {
         title: 'Store sent files on external storage (install step 4)',
         title: 'Store sent files on external storage (install step 4)',

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

@@ -6617,6 +6617,7 @@ export default {
         title: 'Puerto de transferencia de archivos (FTPS 990)',
         title: 'Puerto de transferencia de archivos (FTPS 990)',
         pass: 'Accesible — el envío de archivos de impresión funcionará.',
         pass: 'Accesible — el envío de archivos de impresión funcionará.',
         warn: 'El puerto 990 no es accesible. La supervisión puede seguir funcionando, pero el envío de impresiones a la impresora fallará. Asegúrese de que el puerto 990 no esté bloqueado.',
         warn: 'El puerto 990 no es accesible. La supervisión puede seguir funcionando, pero el envío de impresiones a la impresora fallará. Asegúrese de que el puerto 990 no esté bloqueado.',
+        warn_no_tls: 'El puerto 990 está abierto, pero el servicio de archivos de la impresora no completa el protocolo de enlace TLS. No se pueden obtener los archivos de impresión, las portadas ni los timelapses, por lo que los archivos quedan vacíos. Reinicie la impresora — desbloquear el puerto no servirá.',
       },
       },
       external_storage: {
       external_storage: {
         title: 'Almacenar archivos enviados en almacenamiento externo (paso 4 de instalación)',
         title: 'Almacenar archivos enviados en almacenamiento externo (paso 4 de instalación)',

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

@@ -6598,6 +6598,7 @@ export default {
         title: 'Port de transfert de fichiers (FTPS 990)',
         title: 'Port de transfert de fichiers (FTPS 990)',
         pass: 'Accessible — l\'envoi de fichiers d\'impression fonctionnera.',
         pass: 'Accessible — l\'envoi de fichiers d\'impression fonctionnera.',
         warn: 'Le port 990 est inaccessible. La surveillance peut toujours fonctionner, mais l\'envoi d\'impressions vers l\'imprimante échouera. Assurez-vous que le port 990 n\'est pas bloqué.',
         warn: 'Le port 990 est inaccessible. La surveillance peut toujours fonctionner, mais l\'envoi d\'impressions vers l\'imprimante échouera. Assurez-vous que le port 990 n\'est pas bloqué.',
+        warn_no_tls: 'Le port 990 est ouvert, mais le service de fichiers de l\'imprimante ne termine pas la négociation TLS. Les fichiers d\'impression, les aperçus et les timelapses ne peuvent pas être récupérés, les archives restent donc vides. Redémarrez l\'imprimante — débloquer le port n\'y changera rien.',
       },
       },
       external_storage: {
       external_storage: {
         title: 'Stocker les fichiers envoyés sur stockage externe (étape 4 de l\'installation)',
         title: 'Stocker les fichiers envoyés sur stockage externe (étape 4 de l\'installation)',

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

@@ -6597,6 +6597,7 @@ export default {
         title: 'Porta trasferimento file (FTPS 990)',
         title: 'Porta trasferimento file (FTPS 990)',
         pass: 'Raggiungibile — l\'invio dei file di stampa funzionerà.',
         pass: 'Raggiungibile — l\'invio dei file di stampa funzionerà.',
         warn: 'La porta 990 non è raggiungibile. Il monitoraggio potrebbe ancora funzionare, ma l\'invio delle stampe alla stampante fallirà. Assicurati che la porta 990 non sia bloccata.',
         warn: 'La porta 990 non è raggiungibile. Il monitoraggio potrebbe ancora funzionare, ma l\'invio delle stampe alla stampante fallirà. Assicurati che la porta 990 non sia bloccata.',
+        warn_no_tls: 'La porta 990 è aperta, ma il servizio file della stampante non completa l\'handshake TLS. I file di stampa, le anteprime e i timelapse non possono essere scaricati, quindi gli archivi restano vuoti. Riavvia la stampante — sbloccare la porta non serve.',
       },
       },
       external_storage: {
       external_storage: {
         title: 'Memorizza file inviati su archiviazione esterna (passo 4 dell\'installazione)',
         title: 'Memorizza file inviati su archiviazione esterna (passo 4 dell\'installazione)',

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

@@ -6609,6 +6609,7 @@ export default {
         title: 'ファイル転送ポート (FTPS 990)',
         title: 'ファイル転送ポート (FTPS 990)',
         pass: '到達可能 — 印刷ファイルの送信は機能します。',
         pass: '到達可能 — 印刷ファイルの送信は機能します。',
         warn: 'ポート990に到達できません。監視は引き続き機能する場合がありますが、プリンターへの印刷送信は失敗します。ポート990がブロックされていないことを確認してください。',
         warn: 'ポート990に到達できません。監視は引き続き機能する場合がありますが、プリンターへの印刷送信は失敗します。ポート990がブロックされていないことを確認してください。',
+        warn_no_tls: 'ポート990は開いていますが、プリンターのファイルサービスがTLSハンドシェイクを完了しません。印刷ファイル、サムネイル、タイムラプスを取得できないため、アーカイブは空のままになります。プリンターを再起動してください — ポートのブロックを解除しても解決しません。',
       },
       },
       external_storage: {
       external_storage: {
         title: '送信ファイルを外部ストレージに保存 (インストール手順4)',
         title: '送信ファイルを外部ストレージに保存 (インストール手順4)',

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

@@ -6681,7 +6681,8 @@ export default {
       port_ftps: {
       port_ftps: {
         title: '파일 전송 포트 (FTPS 990)',
         title: '파일 전송 포트 (FTPS 990)',
         pass: '연결 가능 — 인쇄 파일 전송이 작동합니다.',
         pass: '연결 가능 — 인쇄 파일 전송이 작동합니다.',
-        warn: '포트 990에 연결할 수 없습니다. 모니터링은 작동할 수 있지만 프린터로 파일 전송에 실패합니다. 포트 990이 차단되지 않았는지 확인하세요.'
+        warn: '포트 990에 연결할 수 없습니다. 모니터링은 작동할 수 있지만 프린터로 파일 전송에 실패합니다. 포트 990이 차단되지 않았는지 확인하세요.',
+        warn_no_tls: '포트 990은 열려 있지만 프린터의 파일 서비스가 TLS 핸드셰이크를 완료하지 못합니다. 인쇄 파일, 미리보기, 타임랩스를 가져올 수 없어 아카이브가 비어 있게 됩니다. 프린터를 재시작하세요 — 포트 차단을 해제해도 해결되지 않습니다.'
       },
       },
       external_storage: {
       external_storage: {
         title: '전송된 파일을 외부 저장소에 저장 (설치 단계 4)',
         title: '전송된 파일을 외부 저장소에 저장 (설치 단계 4)',

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

@@ -6597,6 +6597,7 @@ export default {
         title: 'Porta de transferência de arquivos (FTPS 990)',
         title: 'Porta de transferência de arquivos (FTPS 990)',
         pass: 'Acessível — o envio de arquivos de impressão funcionará.',
         pass: 'Acessível — o envio de arquivos de impressão funcionará.',
         warn: 'A porta 990 está inacessível. O monitoramento ainda pode funcionar, mas o envio de impressões para a impressora falhará. Verifique se a porta 990 não está bloqueada.',
         warn: 'A porta 990 está inacessível. O monitoramento ainda pode funcionar, mas o envio de impressões para a impressora falhará. Verifique se a porta 990 não está bloqueada.',
+        warn_no_tls: 'A porta 990 está aberta, mas o serviço de arquivos da impressora não conclui o handshake TLS. Os arquivos de impressão, as capas e os timelapses não podem ser obtidos, então os arquivos ficam vazios. Reinicie a impressora — desbloquear a porta não resolve.',
       },
       },
       external_storage: {
       external_storage: {
         title: 'Armazenar arquivos enviados no armazenamento externo (passo 4 da instalação)',
         title: 'Armazenar arquivos enviados no armazenamento externo (passo 4 da instalação)',

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

@@ -6239,6 +6239,7 @@ export default {
         title: "Порт передачи файлов (FTPS 990)",
         title: "Порт передачи файлов (FTPS 990)",
         pass: "Доступен — отправка файлов печати будет работать.",
         pass: "Доступен — отправка файлов печати будет работать.",
         warn: "Порт 990 недоступен. Мониторинг может работать, но отправка заданий на принтер завершится ошибкой. Убедитесь, что порт 990 не заблокирован.",
         warn: "Порт 990 недоступен. Мониторинг может работать, но отправка заданий на принтер завершится ошибкой. Убедитесь, что порт 990 не заблокирован.",
+        warn_no_tls: "Порт 990 открыт, но файловая служба принтера не завершает рукопожатие TLS. Файлы печати, обложки и таймлапсы получить невозможно, поэтому архивы остаются пустыми. Перезагрузите принтер — разблокировка порта не поможет.",
       },
       },
       external_storage: {
       external_storage: {
         title: "Сохранять отправленные файлы во внешнем хранилище (шаг 4 установки)",
         title: "Сохранять отправленные файлы во внешнем хранилище (шаг 4 установки)",

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

@@ -6548,6 +6548,7 @@ export default {
         title: 'Dosya aktarım portu (FTPS 990)',
         title: 'Dosya aktarım portu (FTPS 990)',
         pass: 'Erişilebilir — baskı dosyaları gönderme çalışacak.',
         pass: 'Erişilebilir — baskı dosyaları gönderme çalışacak.',
         warn: 'Port 990 erişilemez. İzleme yine çalışabilir, ancak yazıcıya baskı gönderme başarısız olacak. Port 990\'ın engellenmediğinden emin olun.',
         warn: 'Port 990 erişilemez. İzleme yine çalışabilir, ancak yazıcıya baskı gönderme başarısız olacak. Port 990\'ın engellenmediğinden emin olun.',
+        warn_no_tls: 'Port 990 açık, ancak yazıcının dosya hizmeti TLS el sıkışmasını tamamlamıyor. Baskı dosyaları, kapak görselleri ve timelapse videoları alınamadığı için arşivler boş kalır. Yazıcıyı yeniden başlatın — portun engelini kaldırmak işe yaramaz.',
       },
       },
       external_storage: {
       external_storage: {
         title: 'Gönderilen dosyaları harici depolamada sakla (kurulum adımı 4)',
         title: 'Gönderilen dosyaları harici depolamada sakla (kurulum adımı 4)',

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

@@ -6652,6 +6652,7 @@ export default {
         title: "Порт передачі файлів (FTPS 990)",
         title: "Порт передачі файлів (FTPS 990)",
         pass: "Доступний — файли друку можна буде надсилати.",
         pass: "Доступний — файли друку можна буде надсилати.",
         warn: "Порт 990 недоступний. Моніторинг може й надалі працювати, але надсилання завдань друку на принтер завершуватиметься помилкою. Переконайтеся, що порт 990 не заблоковано.",
         warn: "Порт 990 недоступний. Моніторинг може й надалі працювати, але надсилання завдань друку на принтер завершуватиметься помилкою. Переконайтеся, що порт 990 не заблоковано.",
+        warn_no_tls: "Порт 990 відкритий, але файлова служба принтера не завершує рукостискання TLS. Файли друку, обкладинки та таймлапси отримати неможливо, тому архіви залишаються порожніми. Перезавантажте принтер — розблокування порту не допоможе.",
       },
       },
       external_storage: {
       external_storage: {
         title: "Зберігання надісланих файлів у зовнішньому сховищі (крок установлення 4)",
         title: "Зберігання надісланих файлів у зовнішньому сховищі (крок установлення 4)",

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

@@ -6596,6 +6596,7 @@ export default {
         title: '文件传输端口(FTPS 990)',
         title: '文件传输端口(FTPS 990)',
         pass: '可达 — 发送打印文件将正常工作。',
         pass: '可达 — 发送打印文件将正常工作。',
         warn: '端口 990 不可达。监控可能仍然有效,但向打印机发送打印任务将失败。请确保端口 990 未被阻止。',
         warn: '端口 990 不可达。监控可能仍然有效,但向打印机发送打印任务将失败。请确保端口 990 未被阻止。',
+        warn_no_tls: '端口 990 已开放,但打印机的文件服务未能完成 TLS 握手。无法获取打印文件、封面图和延时视频,因此归档会保持为空。请重启打印机 — 解除端口封锁无济于事。',
       },
       },
       external_storage: {
       external_storage: {
         title: '将发送的文件存储在外部存储中(安装步骤 4)',
         title: '将发送的文件存储在外部存储中(安装步骤 4)',

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

@@ -6596,6 +6596,7 @@ export default {
         title: '檔案傳輸連接埠(FTPS 990)',
         title: '檔案傳輸連接埠(FTPS 990)',
         pass: '可達 — 傳送列印檔案將正常運作。',
         pass: '可達 — 傳送列印檔案將正常運作。',
         warn: '連接埠 990 無法連線。監控可能仍然有效,但向印表機傳送列印工作將失敗。請確保連接埠 990 未被封鎖。',
         warn: '連接埠 990 無法連線。監控可能仍然有效,但向印表機傳送列印工作將失敗。請確保連接埠 990 未被封鎖。',
+        warn_no_tls: '連接埠 990 已開放,但印表機的檔案服務未能完成 TLS 交握。無法取得列印檔案、封面圖與縮時影片,因此封存會維持空白。請重新啟動印表機 — 解除連接埠封鎖並無幫助。',
       },
       },
       external_storage: {
       external_storage: {
         title: '將傳送的檔案儲存在外部儲存中(安裝步驟 4)',
         title: '將傳送的檔案儲存在外部儲存中(安裝步驟 4)',

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/index-B4S191kl.js


+ 1 - 1
static/index.html

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

Неке датотеке нису приказане због велике количине промена