Procházet zdrojové kódy

FTP auto-detection: try prot_p first, fall back to prot_c for A1

User feedback indicated A1 Mini with current firmware works with prot_p
(protected/SSL data channel), not prot_c as previously assumed. Different
A1 firmware versions have different FTP SSL behavior.

Changes:
- Remove hardcoded assumption that A1 models need prot_c
- Try prot_p first for all models (including A1/A1 Mini)
- If upload/download fails on A1 models, automatically retry with prot_c
- Cache working mode per printer IP for subsequent operations
- Add force_prot_c parameter for explicit mode control

This makes FTP work across A1 firmware versions:
- New firmware: prot_p succeeds, cached
- Old firmware: prot_p fails → prot_c fallback succeeds, cached
maziggy před 3 měsíci
rodič
revize
0279961889
1 změnil soubory, kde provedl 118 přidání a 24 odebrání
  1. 118 24
      backend/app/services/bambu_ftp.py

+ 118 - 24
backend/app/services/bambu_ftp.py

@@ -77,9 +77,13 @@ class BambuFTPClient:
 
 
     FTP_PORT = 990
     FTP_PORT = 990
     DEFAULT_TIMEOUT = 30  # Default timeout in seconds (increased for A1 printers)
     DEFAULT_TIMEOUT = 30  # Default timeout in seconds (increased for A1 printers)
-    # Models that need SSL session reuse disabled (A1 series has FTP issues with session reuse)
-    # X1C/X1E/P1S/P1P/P2S use vsFTPd which requires SSL session reuse - do NOT add them here
-    SKIP_SESSION_REUSE_MODELS = ("A1", "A1 Mini")
+    # Models that may need SSL mode fallback (try prot_p first, fall back to prot_c)
+    # These models have varying FTP SSL behavior depending on firmware version
+    A1_MODELS = ("A1", "A1 Mini")
+
+    # Cache for working FTP modes per printer IP
+    # Maps IP -> "prot_p" or "prot_c"
+    _mode_cache: dict[str, str] = {}
 
 
     def __init__(
     def __init__(
         self,
         self,
@@ -87,45 +91,70 @@ class BambuFTPClient:
         access_code: str,
         access_code: str,
         timeout: float | None = None,
         timeout: float | None = None,
         printer_model: str | None = None,
         printer_model: str | None = None,
+        force_prot_c: bool = False,
     ):
     ):
         self.ip_address = ip_address
         self.ip_address = ip_address
         self.access_code = access_code
         self.access_code = access_code
         self.timeout = timeout if timeout is not None else self.DEFAULT_TIMEOUT
         self.timeout = timeout if timeout is not None else self.DEFAULT_TIMEOUT
         self.printer_model = printer_model
         self.printer_model = printer_model
+        self.force_prot_c = force_prot_c
         self._ftp: ImplicitFTP_TLS | None = None
         self._ftp: ImplicitFTP_TLS | None = None
 
 
-    def _should_skip_session_reuse(self) -> bool:
-        """Check if this printer model needs SSL session reuse disabled."""
+    def _is_a1_model(self) -> bool:
+        """Check if this is an A1 series printer."""
         if not self.printer_model:
         if not self.printer_model:
             return False
             return False
-        return self.printer_model in self.SKIP_SESSION_REUSE_MODELS
+        return self.printer_model in self.A1_MODELS
+
+    def _get_cached_mode(self) -> str | None:
+        """Get cached FTP mode for this printer."""
+        return self._mode_cache.get(self.ip_address)
+
+    @classmethod
+    def cache_mode(cls, ip_address: str, mode: str):
+        """Cache the working FTP mode for a printer."""
+        cls._mode_cache[ip_address] = mode
+        logger.info(f"FTP mode cached for {ip_address}: {mode}")
+
+    def _should_use_prot_c(self) -> bool:
+        """Determine if we should use prot_c (clear) mode."""
+        # If explicitly forced, use prot_c
+        if self.force_prot_c:
+            return True
+        # Check cache first
+        cached = self._get_cached_mode()
+        if cached:
+            return cached == "prot_c"
+        # Default: try prot_p first (will fall back if needed)
+        return False
 
 
     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)."""
         try:
         try:
-            skip_reuse = self._should_skip_session_reuse()
+            use_prot_c = self._should_use_prot_c()
             logger.debug(
             logger.debug(
                 f"FTP connecting to {self.ip_address}:{self.FTP_PORT} "
                 f"FTP connecting to {self.ip_address}:{self.FTP_PORT} "
-                f"(timeout={self.timeout}s, model={self.printer_model}, skip_session_reuse={skip_reuse})"
+                f"(timeout={self.timeout}s, model={self.printer_model}, prot_c={use_prot_c})"
             )
             )
-            self._ftp = ImplicitFTP_TLS(skip_session_reuse=skip_reuse)
+            self._ftp = ImplicitFTP_TLS(skip_session_reuse=use_prot_c)
             self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=self.timeout)
             self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=self.timeout)
             logger.debug("FTP connected, logging in as bblp")
             logger.debug("FTP connected, logging in as bblp")
             self._ftp.login("bblp", self.access_code)
             self._ftp.login("bblp", self.access_code)
-            if skip_reuse:
-                # A1/A1 Mini: Use clear (unencrypted) data channel
-                # These printers have issues with SSL on the data channel
-                logger.debug("FTP logged in, setting prot_c (clear) and passive mode for A1")
+            if use_prot_c:
+                # Use clear (unencrypted) data channel
+                logger.debug("FTP logged in, setting prot_c (clear) and passive mode")
                 self._ftp.prot_c()
                 self._ftp.prot_c()
             else:
             else:
-                # X1C/P1S/etc: Use protected (encrypted) data channel with session reuse
+                # Use protected (encrypted) data channel with session reuse
                 logger.debug("FTP logged in, setting prot_p (protected) and passive mode")
                 logger.debug("FTP logged in, setting prot_p (protected) and passive mode")
                 self._ftp.prot_p()
                 self._ftp.prot_p()
             self._ftp.set_pasv(True)
             self._ftp.set_pasv(True)
             # Log welcome message for debugging
             # Log welcome message for debugging
             if hasattr(self._ftp, "welcome") and self._ftp.welcome:
             if hasattr(self._ftp, "welcome") and self._ftp.welcome:
                 logger.debug(f"FTP server welcome: {self._ftp.welcome}")
                 logger.debug(f"FTP server welcome: {self._ftp.welcome}")
-            logger.info(f"FTP connected successfully to {self.ip_address} (model={self.printer_model})")
+            logger.info(
+                f"FTP connected successfully to {self.ip_address} (model={self.printer_model}, prot_c={use_prot_c})"
+            )
             return True
             return True
         except ftplib.error_perm as e:
         except ftplib.error_perm as e:
             logger.warning(f"FTP connection permission error to {self.ip_address}: {e}")
             logger.warning(f"FTP connection permission error to {self.ip_address}: {e}")
@@ -462,6 +491,9 @@ async def download_file_async(
 ) -> bool:
 ) -> bool:
     """Async wrapper for downloading a file with timeout.
     """Async wrapper for downloading a file with timeout.
 
 
+    For A1/A1 Mini printers, automatically tries prot_p first, then falls back
+    to prot_c if the download fails. The working mode is cached for future operations.
+
     Args:
     Args:
         ip_address: Printer IP address
         ip_address: Printer IP address
         access_code: Printer access code
         access_code: Printer access code
@@ -472,18 +504,47 @@ async def download_file_async(
         printer_model: Printer model for A1-specific workarounds
         printer_model: Printer model for A1-specific workarounds
     """
     """
     loop = asyncio.get_event_loop()
     loop = asyncio.get_event_loop()
+    is_a1 = printer_model in BambuFTPClient.A1_MODELS if printer_model else False
 
 
-    def _download():
-        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
+    def _download(force_prot_c: bool = False) -> bool:
+        mode_str = "prot_c" if force_prot_c else "prot_p"
+        client = BambuFTPClient(
+            ip_address, access_code, timeout=socket_timeout, printer_model=printer_model, force_prot_c=force_prot_c
+        )
         if client.connect():
         if client.connect():
             try:
             try:
-                return client.download_to_file(remote_path, local_path)
+                result = client.download_to_file(remote_path, local_path)
+                if result:
+                    # Cache the working mode
+                    BambuFTPClient.cache_mode(ip_address, mode_str)
+                return result
             finally:
             finally:
                 client.disconnect()
                 client.disconnect()
         return False
         return False
 
 
     try:
     try:
-        return await asyncio.wait_for(loop.run_in_executor(None, _download), timeout=timeout)
+        # Check if we have a cached mode for this printer
+        cached_mode = BambuFTPClient._mode_cache.get(ip_address)
+
+        if cached_mode:
+            # Use cached mode
+            force_prot_c = cached_mode == "prot_c"
+            return await asyncio.wait_for(loop.run_in_executor(None, lambda: _download(force_prot_c)), timeout=timeout)
+
+        # No cached mode - try prot_p first
+        result = await asyncio.wait_for(loop.run_in_executor(None, lambda: _download(False)), timeout=timeout)
+
+        if result:
+            return True
+
+        # Download failed - for A1 models, try prot_c fallback
+        if is_a1:
+            logger.info("FTP download failed with prot_p for A1 model, trying prot_c fallback...")
+            result = await asyncio.wait_for(loop.run_in_executor(None, lambda: _download(True)), timeout=timeout)
+            return result
+
+        return False
+
     except TimeoutError:
     except TimeoutError:
         logger.warning(f"FTP download timed out after {timeout}s for {remote_path}")
         logger.warning(f"FTP download timed out after {timeout}s for {remote_path}")
         return False
         return False
@@ -530,6 +591,9 @@ async def upload_file_async(
 ) -> bool:
 ) -> bool:
     """Async wrapper for uploading a file with timeout and progress callback.
     """Async wrapper for uploading a file with timeout and progress callback.
 
 
+    For A1/A1 Mini printers, automatically tries prot_p first, then falls back
+    to prot_c if the upload fails. The working mode is cached for future uploads.
+
     Args:
     Args:
         ip_address: Printer IP address
         ip_address: Printer IP address
         access_code: Printer access code
         access_code: Printer access code
@@ -541,23 +605,53 @@ async def upload_file_async(
         printer_model: Printer model for A1-specific workarounds
         printer_model: Printer model for A1-specific workarounds
     """
     """
     loop = asyncio.get_event_loop()
     loop = asyncio.get_event_loop()
+    is_a1 = printer_model in BambuFTPClient.A1_MODELS if printer_model else False
 
 
-    def _upload():
+    def _upload(force_prot_c: bool = False) -> bool:
+        mode_str = "prot_c" if force_prot_c else "prot_p"
         logger.info(
         logger.info(
-            f"FTP connecting to {ip_address} for upload (model={printer_model}, socket_timeout={socket_timeout}s)..."
+            f"FTP connecting to {ip_address} for upload (model={printer_model}, "
+            f"mode={mode_str}, socket_timeout={socket_timeout}s)..."
+        )
+        client = BambuFTPClient(
+            ip_address, access_code, timeout=socket_timeout, printer_model=printer_model, force_prot_c=force_prot_c
         )
         )
-        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
         if client.connect():
         if client.connect():
             logger.info(f"FTP connected to {ip_address}")
             logger.info(f"FTP connected to {ip_address}")
             try:
             try:
-                return client.upload_file(local_path, remote_path, progress_callback)
+                result = client.upload_file(local_path, remote_path, progress_callback)
+                if result:
+                    # Cache the working mode
+                    BambuFTPClient.cache_mode(ip_address, mode_str)
+                return result
             finally:
             finally:
                 client.disconnect()
                 client.disconnect()
         logger.warning(f"FTP connection failed to {ip_address}")
         logger.warning(f"FTP connection failed to {ip_address}")
         return False
         return False
 
 
     try:
     try:
-        return await asyncio.wait_for(loop.run_in_executor(None, _upload), timeout=timeout)
+        # Check if we have a cached mode for this printer
+        cached_mode = BambuFTPClient._mode_cache.get(ip_address)
+
+        if cached_mode:
+            # Use cached mode
+            force_prot_c = cached_mode == "prot_c"
+            return await asyncio.wait_for(loop.run_in_executor(None, lambda: _upload(force_prot_c)), timeout=timeout)
+
+        # No cached mode - try prot_p first
+        result = await asyncio.wait_for(loop.run_in_executor(None, lambda: _upload(False)), timeout=timeout)
+
+        if result:
+            return True
+
+        # Upload failed - for A1 models, try prot_c fallback
+        if is_a1:
+            logger.info("FTP upload failed with prot_p for A1 model, trying prot_c fallback...")
+            result = await asyncio.wait_for(loop.run_in_executor(None, lambda: _upload(True)), timeout=timeout)
+            return result
+
+        return False
+
     except TimeoutError:
     except TimeoutError:
         logger.warning(f"FTP upload timed out after {timeout}s for {remote_path}")
         logger.warning(f"FTP upload timed out after {timeout}s for {remote_path}")
         return False
         return False