Browse Source

Fix A1/A1 Mini FTP compatibility and add configurable timeout

Resolves FTP transfer failures on A1/A1 Mini printers caused by SSL
session reuse incompatibility. Also adds configurable FTP timeout
setting for slow WiFi connections.

Changes:
  - Add SSL session reuse bypass for A1/A1 Mini printers (auto-detected)
  - Add printer_model parameter to FTP functions for model detection
  - Add configurable FTP timeout (10-120s, default 30s) in Settings
  - Update all FTP callers: archiving, reprint, timelapse, firmware upload

Technical details:
  - A1/A1 Mini printers don't support vsFTPd's SSL session reuse
  - X1C/P1S continue to use session reuse as required by their FTP server
  - ImplicitFTP_TLS now accepts skip_session_reuse flag
  - BambuFTPClient auto-detects A1/A1 Mini and sets flag accordingly
maziggy 4 months ago
parent
commit
0e3e37ffc1

+ 6 - 0
CHANGELOG.md

@@ -26,8 +26,14 @@ All notable changes to Bambuddy will be documented in this file.
 - **FTP retry for unreliable WiFi** - Configurable retry logic for all FTP operations:
   - Enable/disable retry in Settings > General > FTP Retry
   - Configure retry count (1-10 attempts) and delay (1-30 seconds)
+  - Configurable connection timeout (10-120 seconds, default 30s)
   - Applies to: 3MF archiving, print uploads, timelapse downloads, firmware updates
   - Helps P1S, X1C, and other printers with weak WiFi connections
+- **A1/A1 Mini FTP fix** - Resolved FTP upload failures on A1 series printers:
+  - A1 printers don't support SSL session reuse on data connections
+  - Automatic detection and workaround for A1 and A1 Mini models
+  - Maintains full encryption while skipping problematic session reuse
+  - Fixes "read operation timed out" errors during file uploads
 
 ### Fixed
 - **QR code endpoint** - Fixed 500 error on archive QR code generation:

+ 29 - 7
backend/app/api/routes/archives.py

@@ -1088,7 +1088,7 @@ async def scan_timelapse(
     remote_path = matching_file.get("path") or f"/timelapse/{matching_file['name']}"
 
     # Get FTP retry settings
-    ftp_retry_enabled, ftp_retry_count, ftp_retry_delay = await get_ftp_retry_settings()
+    ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
 
     if ftp_retry_enabled:
         timelapse_data = await with_ftp_retry(
@@ -1096,12 +1096,20 @@ async def scan_timelapse(
             printer.ip_address,
             printer.access_code,
             remote_path,
+            socket_timeout=ftp_timeout,
+            printer_model=printer.model,
             max_retries=ftp_retry_count,
             retry_delay=ftp_retry_delay,
             operation_name=f"Download timelapse {matching_file['name']}",
         )
     else:
-        timelapse_data = await download_file_bytes_async(printer.ip_address, printer.access_code, remote_path)
+        timelapse_data = await download_file_bytes_async(
+            printer.ip_address,
+            printer.access_code,
+            remote_path,
+            socket_timeout=ftp_timeout,
+            printer_model=printer.model,
+        )
 
     if not timelapse_data:
         raise HTTPException(500, "Failed to download timelapse")
@@ -1166,7 +1174,7 @@ async def select_timelapse(
         raise HTTPException(404, f"Timelapse '{filename}' not found on printer")
 
     # Download and attach
-    ftp_retry_enabled, ftp_retry_count, ftp_retry_delay = await get_ftp_retry_settings()
+    ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
 
     if ftp_retry_enabled:
         timelapse_data = await with_ftp_retry(
@@ -1174,12 +1182,20 @@ async def select_timelapse(
             printer.ip_address,
             printer.access_code,
             remote_path,
+            socket_timeout=ftp_timeout,
+            printer_model=printer.model,
             max_retries=ftp_retry_count,
             retry_delay=ftp_retry_delay,
             operation_name=f"Download timelapse {filename}",
         )
     else:
-        timelapse_data = await download_file_bytes_async(printer.ip_address, printer.access_code, remote_path)
+        timelapse_data = await download_file_bytes_async(
+            printer.ip_address,
+            printer.access_code,
+            remote_path,
+            socket_timeout=ftp_timeout,
+            printer_model=printer.model,
+        )
 
     if not timelapse_data:
         raise HTTPException(500, "Failed to download timelapse")
@@ -2071,16 +2087,18 @@ async def reprint_archive(
     remote_filename = f"{base_name}.3mf"
     remote_path = f"/{remote_filename}"
 
+    # Get FTP retry settings
+    ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
+
     # Delete existing file if present (avoids 553 error)
     await delete_file_async(
         printer.ip_address,
         printer.access_code,
         remote_path,
+        socket_timeout=ftp_timeout,
+        printer_model=printer.model,
     )
 
-    # Get FTP retry settings
-    ftp_retry_enabled, ftp_retry_count, ftp_retry_delay = await get_ftp_retry_settings()
-
     if ftp_retry_enabled:
         uploaded = await with_ftp_retry(
             upload_file_async,
@@ -2088,6 +2106,8 @@ async def reprint_archive(
             printer.access_code,
             file_path,
             remote_path,
+            socket_timeout=ftp_timeout,
+            printer_model=printer.model,
             max_retries=ftp_retry_count,
             retry_delay=ftp_retry_delay,
             operation_name=f"Upload for reprint to {printer.name}",
@@ -2098,6 +2118,8 @@ async def reprint_archive(
             printer.access_code,
             file_path,
             remote_path,
+            socket_timeout=ftp_timeout,
+            printer_model=printer.model,
         )
 
     if not uploaded:

+ 5 - 1
backend/app/main.py

@@ -590,7 +590,7 @@ async def on_print_start(printer_id: int, data: dict):
         downloaded_filename = None
 
         # Get FTP retry settings
-        ftp_retry_enabled, ftp_retry_count, ftp_retry_delay = await get_ftp_retry_settings()
+        ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
 
         for try_filename in possible_names:
             if not try_filename.endswith(".3mf"):
@@ -615,6 +615,8 @@ async def on_print_start(printer_id: int, data: dict):
                             printer.access_code,
                             remote_path,
                             temp_path,
+                            socket_timeout=ftp_timeout,
+                            printer_model=printer.model,
                             max_retries=ftp_retry_count,
                             retry_delay=ftp_retry_delay,
                             operation_name=f"Download 3MF from {remote_path}",
@@ -625,6 +627,8 @@ async def on_print_start(printer_id: int, data: dict):
                             printer.access_code,
                             remote_path,
                             temp_path,
+                            socket_timeout=ftp_timeout,
+                            printer_model=printer.model,
                         )
                     if downloaded:
                         downloaded_filename = try_filename

+ 136 - 31
backend/app/services/bambu_ftp.py

@@ -15,11 +15,16 @@ T = TypeVar("T")
 
 
 class ImplicitFTP_TLS(FTP_TLS):
-    """FTP_TLS subclass for implicit FTPS (port 990) with session reuse."""
+    """FTP_TLS subclass for implicit FTPS (port 990) with optional session reuse.
 
-    def __init__(self, *args, **kwargs):
+    SSL session reuse is required by X1C/P1S printers (vsFTPd), but causes issues
+    with A1/A1 Mini printers. Set skip_session_reuse=True for A1 printers.
+    """
+
+    def __init__(self, *args, skip_session_reuse: bool = False, **kwargs):
         super().__init__(*args, **kwargs)
         self._sock = None
+        self.skip_session_reuse = skip_session_reuse
         self.ssl_context = ssl.create_default_context()
         self.ssl_context.check_hostname = False
         self.ssl_context.verify_mode = ssl.CERT_NONE
@@ -44,15 +49,27 @@ class ImplicitFTP_TLS(FTP_TLS):
         return self.welcome
 
     def ntransfercmd(self, cmd, rest=None):
-        """Override to reuse SSL session for data connection (required by vsFTPd)."""
+        """Override to wrap data connection in SSL.
+
+        Session reuse is required by X1C/P1S (vsFTPd) but breaks A1/A1 Mini printers.
+        When skip_session_reuse is True, we still encrypt the data channel but
+        don't reuse the control connection's session.
+        """
         conn, size = FTP.ntransfercmd(self, cmd, rest)
         if self._prot_p:
-            # Reuse the SSL session from the control connection
-            conn = self.ssl_context.wrap_socket(
-                conn,
-                server_hostname=self.host,
-                session=self.sock.session,  # Reuse session!
-            )
+            if self.skip_session_reuse:
+                # A1/A1 Mini: Don't reuse session (causes timeouts/hangs)
+                conn = self.ssl_context.wrap_socket(
+                    conn,
+                    server_hostname=self.host,
+                )
+            else:
+                # X1C/P1S: Reuse SSL session (required by vsFTPd)
+                conn = self.ssl_context.wrap_socket(
+                    conn,
+                    server_hostname=self.host,
+                    session=self.sock.session,
+                )
         return conn, size
 
 
@@ -60,18 +77,39 @@ class BambuFTPClient:
     """FTP client for retrieving files from Bambu Lab printers."""
 
     FTP_PORT = 990
+    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)
+    SKIP_SESSION_REUSE_MODELS = ("A1", "A1 Mini")
 
-    def __init__(self, ip_address: str, access_code: str):
+    def __init__(
+        self,
+        ip_address: str,
+        access_code: str,
+        timeout: float | None = None,
+        printer_model: str | None = None,
+    ):
         self.ip_address = ip_address
         self.access_code = access_code
+        self.timeout = timeout if timeout is not None else self.DEFAULT_TIMEOUT
+        self.printer_model = printer_model
         self._ftp: ImplicitFTP_TLS | None = None
 
+    def _should_skip_session_reuse(self) -> bool:
+        """Check if this printer model needs SSL session reuse disabled."""
+        if not self.printer_model:
+            return False
+        return self.printer_model in self.SKIP_SESSION_REUSE_MODELS
+
     def connect(self) -> bool:
         """Connect to the printer FTP server (implicit FTPS on port 990)."""
         try:
-            logger.debug(f"FTP connecting to {self.ip_address}:{self.FTP_PORT}")
-            self._ftp = ImplicitFTP_TLS()
-            self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=10)
+            skip_reuse = self._should_skip_session_reuse()
+            logger.debug(
+                f"FTP connecting to {self.ip_address}:{self.FTP_PORT} "
+                f"(timeout={self.timeout}s, model={self.printer_model}, skip_session_reuse={skip_reuse})"
+            )
+            self._ftp = ImplicitFTP_TLS(skip_session_reuse=skip_reuse)
+            self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=self.timeout)
             logger.debug("FTP connected, logging in as bblp")
             self._ftp.login("bblp", self.access_code)
             logger.debug("FTP logged in, setting prot_p and passive mode")
@@ -314,12 +352,24 @@ async def download_file_async(
     remote_path: str,
     local_path: Path,
     timeout: float = 60.0,
+    socket_timeout: float | None = None,
+    printer_model: str | None = None,
 ) -> bool:
-    """Async wrapper for downloading a file with timeout."""
+    """Async wrapper for downloading a file with timeout.
+
+    Args:
+        ip_address: Printer IP address
+        access_code: Printer access code
+        remote_path: Remote file path on printer
+        local_path: Local path to save file
+        timeout: Overall operation timeout (asyncio)
+        socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
+        printer_model: Printer model for A1-specific workarounds
+    """
     loop = asyncio.get_event_loop()
 
     def _download():
-        client = BambuFTPClient(ip_address, access_code)
+        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
         if client.connect():
             try:
                 return client.download_to_file(remote_path, local_path)
@@ -339,12 +389,19 @@ async def download_file_try_paths_async(
     access_code: str,
     remote_paths: list[str],
     local_path: Path,
+    socket_timeout: float | None = None,
+    printer_model: str | None = None,
 ) -> bool:
-    """Try downloading a file from multiple paths using a single connection."""
+    """Try downloading a file from multiple paths using a single connection.
+
+    Args:
+        socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
+        printer_model: Printer model for A1-specific workarounds
+    """
     loop = asyncio.get_event_loop()
 
     def _download():
-        client = BambuFTPClient(ip_address, access_code)
+        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
         if not client.connect():
             return False
 
@@ -363,13 +420,28 @@ async def upload_file_async(
     remote_path: str,
     timeout: float = 600.0,
     progress_callback: Callable[[int, int], None] | None = None,
+    socket_timeout: float | None = None,
+    printer_model: str | None = None,
 ) -> bool:
-    """Async wrapper for uploading a file with timeout and progress callback."""
+    """Async wrapper for uploading a file with timeout and progress callback.
+
+    Args:
+        ip_address: Printer IP address
+        access_code: Printer access code
+        local_path: Local file path to upload
+        remote_path: Remote path on printer
+        timeout: Overall operation timeout (asyncio)
+        progress_callback: Optional callback for progress updates
+        socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
+        printer_model: Printer model for A1-specific workarounds
+    """
     loop = asyncio.get_event_loop()
 
     def _upload():
-        logger.info(f"FTP connecting to {ip_address} for upload...")
-        client = BambuFTPClient(ip_address, access_code)
+        logger.info(
+            f"FTP connecting to {ip_address} for upload (model={printer_model}, socket_timeout={socket_timeout}s)..."
+        )
+        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
         if client.connect():
             logger.info(f"FTP connected to {ip_address}")
             try:
@@ -391,12 +463,19 @@ async def list_files_async(
     access_code: str,
     path: str = "/",
     timeout: float = 30.0,
+    socket_timeout: float | None = None,
+    printer_model: str | None = None,
 ) -> list[dict]:
-    """Async wrapper for listing files with timeout."""
+    """Async wrapper for listing files with timeout.
+
+    Args:
+        socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
+        printer_model: Printer model for A1-specific workarounds
+    """
     loop = asyncio.get_event_loop()
 
     def _list():
-        client = BambuFTPClient(ip_address, access_code)
+        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
         if client.connect():
             try:
                 return client.list_files(path)
@@ -415,12 +494,19 @@ async def delete_file_async(
     ip_address: str,
     access_code: str,
     remote_path: str,
+    socket_timeout: float | None = None,
+    printer_model: str | None = None,
 ) -> bool:
-    """Async wrapper for deleting a file."""
+    """Async wrapper for deleting a file.
+
+    Args:
+        socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
+        printer_model: Printer model for A1-specific workarounds
+    """
     loop = asyncio.get_event_loop()
 
     def _delete():
-        client = BambuFTPClient(ip_address, access_code)
+        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
         if client.connect():
             try:
                 return client.delete_file(remote_path)
@@ -435,12 +521,19 @@ async def download_file_bytes_async(
     ip_address: str,
     access_code: str,
     remote_path: str,
+    socket_timeout: float | None = None,
+    printer_model: str | None = None,
 ) -> bytes | None:
-    """Async wrapper for downloading file as bytes."""
+    """Async wrapper for downloading file as bytes.
+
+    Args:
+        socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
+        printer_model: Printer model for A1-specific workarounds
+    """
     loop = asyncio.get_event_loop()
 
     def _download():
-        client = BambuFTPClient(ip_address, access_code)
+        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
         if client.connect():
             try:
                 return client.download_file(remote_path)
@@ -454,12 +547,19 @@ async def download_file_bytes_async(
 async def get_storage_info_async(
     ip_address: str,
     access_code: str,
+    socket_timeout: float | None = None,
+    printer_model: str | None = None,
 ) -> dict | None:
-    """Async wrapper for getting storage info."""
+    """Async wrapper for getting storage info.
+
+    Args:
+        socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
+        printer_model: Printer model for A1-specific workarounds
+    """
     loop = asyncio.get_event_loop()
 
     def _get_storage():
-        client = BambuFTPClient(ip_address, access_code)
+        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
         if client.connect():
             try:
                 return client.get_storage_info()
@@ -470,8 +570,12 @@ async def get_storage_info_async(
     return await loop.run_in_executor(None, _get_storage)
 
 
-async def get_ftp_retry_settings() -> tuple[bool, int, float]:
-    """Get FTP retry settings from database."""
+async def get_ftp_retry_settings() -> tuple[bool, int, float, float]:
+    """Get FTP retry settings from database.
+
+    Returns:
+        Tuple of (retry_enabled, retry_count, retry_delay, timeout)
+    """
     from backend.app.api.routes.settings import get_setting
     from backend.app.core.database import async_session
 
@@ -479,7 +583,8 @@ async def get_ftp_retry_settings() -> tuple[bool, int, float]:
         enabled = (await get_setting(db, "ftp_retry_enabled") or "true") == "true"
         count = int(await get_setting(db, "ftp_retry_count") or "3")
         delay = float(await get_setting(db, "ftp_retry_delay") or "2")
-    return enabled, count, delay
+        timeout = float(await get_setting(db, "ftp_timeout") or "30")
+    return enabled, count, delay, timeout
 
 
 async def with_ftp_retry(

+ 5 - 1
backend/app/services/firmware_update.py

@@ -302,7 +302,7 @@ class FirmwareUpdateService:
                         asyncio.run_coroutine_threadsafe(self._broadcast_progress(printer_id, state), loop)
 
             # Get FTP retry settings
-            ftp_retry_enabled, ftp_retry_count, ftp_retry_delay = await get_ftp_retry_settings()
+            ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
 
             if ftp_retry_enabled:
                 success = await with_ftp_retry(
@@ -312,6 +312,8 @@ class FirmwareUpdateService:
                     firmware_path,
                     remote_path,
                     progress_callback=on_upload_progress,
+                    socket_timeout=ftp_timeout,
+                    printer_model=model,
                     max_retries=ftp_retry_count,
                     retry_delay=ftp_retry_delay,
                     operation_name=f"Upload firmware to printer {printer_id}",
@@ -323,6 +325,8 @@ class FirmwareUpdateService:
                     firmware_path,
                     remote_path,
                     progress_callback=on_upload_progress,
+                    socket_timeout=ftp_timeout,
+                    printer_model=model,
                 )
 
             if not success:

+ 5 - 1
backend/app/services/print_scheduler.py

@@ -272,7 +272,7 @@ class PrintScheduler:
         remote_path = f"/cache/{remote_filename}"
 
         # Get FTP retry settings
-        ftp_retry_enabled, ftp_retry_count, ftp_retry_delay = await get_ftp_retry_settings()
+        ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
 
         try:
             if ftp_retry_enabled:
@@ -282,6 +282,8 @@ class PrintScheduler:
                     printer.access_code,
                     file_path,
                     remote_path,
+                    socket_timeout=ftp_timeout,
+                    printer_model=printer.model,
                     max_retries=ftp_retry_count,
                     retry_delay=ftp_retry_delay,
                     operation_name=f"Upload print to {printer.name}",
@@ -292,6 +294,8 @@ class PrintScheduler:
                     printer.access_code,
                     file_path,
                     remote_path,
+                    socket_timeout=ftp_timeout,
+                    printer_model=printer.model,
                 )
         except Exception as e:
             uploaded = False

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

@@ -568,6 +568,7 @@ export interface AppSettings {
   ftp_retry_enabled: boolean;
   ftp_retry_count: number;
   ftp_retry_delay: number;
+  ftp_timeout: number;
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;

+ 23 - 1
frontend/src/pages/SettingsPage.tsx

@@ -347,7 +347,8 @@ export function SettingsPage() {
       settings.default_printer_id !== localSettings.default_printer_id ||
       settings.ftp_retry_enabled !== localSettings.ftp_retry_enabled ||
       settings.ftp_retry_count !== localSettings.ftp_retry_count ||
-      settings.ftp_retry_delay !== localSettings.ftp_retry_delay;
+      settings.ftp_retry_delay !== localSettings.ftp_retry_delay ||
+      settings.ftp_timeout !== localSettings.ftp_timeout;
 
     if (!hasChanges) {
       return;
@@ -383,6 +384,7 @@ export function SettingsPage() {
         ftp_retry_enabled: localSettings.ftp_retry_enabled,
         ftp_retry_count: localSettings.ftp_retry_count,
         ftp_retry_delay: localSettings.ftp_retry_delay,
+        ftp_timeout: localSettings.ftp_timeout,
       };
       updateMutation.mutate(settingsToSave);
     }, 500);
@@ -1086,6 +1088,26 @@ export function SettingsPage() {
                   </div>
                 </div>
               )}
+
+              <div className="pt-2 border-t border-bambu-dark-tertiary">
+                <label className="block text-sm text-bambu-gray mb-1">
+                  Connection timeout
+                </label>
+                <div className="flex items-center gap-2">
+                  <input
+                    type="number"
+                    min="10"
+                    max="120"
+                    value={localSettings.ftp_timeout ?? 30}
+                    onChange={(e) => updateSetting('ftp_timeout', Math.min(120, Math.max(10, parseInt(e.target.value) || 30)))}
+                    className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                  />
+                  <span className="text-bambu-gray">seconds</span>
+                </div>
+                <p className="text-xs text-bambu-gray mt-1">
+                  Socket timeout for slow connections. Increase for A1/A1 Mini printers with weak WiFi (10-120)
+                </p>
+              </div>
             </CardContent>
           </Card>
         </div>

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


+ 1 - 1
static/index.html

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

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