Bladeren bron

Add FTP retry for unreliable WiFi connections

  Added configurable retry logic for all FTP operations to handle
  unreliable WiFi on P1S, X1C, and other Bambu Lab printers.

  Features:
  - Enable/disable retry in Settings > General > FTP Retry
  - Configurable retry count (1-10 attempts, default: 3)
  - Configurable retry delay (1-30 seconds, default: 2s)
  - Applies to: 3MF archiving, print uploads, timelapse downloads,
    reprint uploads, and firmware updates
maziggy 4 maanden geleden
bovenliggende
commit
d8cecb0695

+ 5 - 0
CHANGELOG.md

@@ -23,6 +23,11 @@ All notable changes to Bambuddy will be documented in this file.
 - **Spoolman location clearing** - When spools are removed from AMS, their location field is now cleared in Spoolman:
   - Previously, location persisted even after spool removal
   - Now correctly clears "Printer Name - AMS X Slot Y" when spool is no longer present
+- **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)
+  - Applies to: 3MF archiving, print uploads, timelapse downloads, firmware updates
+  - Helps P1S, X1C, and other printers with weak WiFi connections
 
 ### Fixed
 - **QR code endpoint** - Fixed 500 error on archive QR code generation:

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

@@ -909,7 +909,12 @@ async def scan_timelapse(
 ):
     """Scan printer for timelapse matching this archive and attach it."""
     from backend.app.models.printer import Printer
-    from backend.app.services.bambu_ftp import download_file_bytes_async, list_files_async
+    from backend.app.services.bambu_ftp import (
+        download_file_bytes_async,
+        get_ftp_retry_settings,
+        list_files_async,
+        with_ftp_retry,
+    )
 
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -1081,7 +1086,22 @@ async def scan_timelapse(
 
     # Download the timelapse - use the full path from the file listing
     remote_path = matching_file.get("path") or f"/timelapse/{matching_file['name']}"
-    timelapse_data = await download_file_bytes_async(printer.ip_address, printer.access_code, remote_path)
+
+    # Get FTP retry settings
+    ftp_retry_enabled, ftp_retry_count, ftp_retry_delay = await get_ftp_retry_settings()
+
+    if ftp_retry_enabled:
+        timelapse_data = await with_ftp_retry(
+            download_file_bytes_async,
+            printer.ip_address,
+            printer.access_code,
+            remote_path,
+            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)
 
     if not timelapse_data:
         raise HTTPException(500, "Failed to download timelapse")
@@ -1107,7 +1127,12 @@ async def select_timelapse(
 ):
     """Manually select a timelapse from the printer to attach."""
     from backend.app.models.printer import Printer
-    from backend.app.services.bambu_ftp import download_file_bytes_async, list_files_async
+    from backend.app.services.bambu_ftp import (
+        download_file_bytes_async,
+        get_ftp_retry_settings,
+        list_files_async,
+        with_ftp_retry,
+    )
 
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -1141,7 +1166,21 @@ async def select_timelapse(
         raise HTTPException(404, f"Timelapse '{filename}' not found on printer")
 
     # Download and attach
-    timelapse_data = await download_file_bytes_async(printer.ip_address, printer.access_code, remote_path)
+    ftp_retry_enabled, ftp_retry_count, ftp_retry_delay = await get_ftp_retry_settings()
+
+    if ftp_retry_enabled:
+        timelapse_data = await with_ftp_retry(
+            download_file_bytes_async,
+            printer.ip_address,
+            printer.access_code,
+            remote_path,
+            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)
+
     if not timelapse_data:
         raise HTTPException(500, "Failed to download timelapse")
 
@@ -1987,7 +2026,11 @@ async def reprint_archive(
     """Send an archived 3MF file to a printer and start printing."""
     from backend.app.main import register_expected_print
     from backend.app.models.printer import Printer
-    from backend.app.services.bambu_ftp import upload_file_async
+    from backend.app.services.bambu_ftp import (
+        get_ftp_retry_settings,
+        upload_file_async,
+        with_ftp_retry,
+    )
     from backend.app.services.printer_manager import printer_manager
 
     # Use defaults if no body provided
@@ -2035,12 +2078,27 @@ async def reprint_archive(
         remote_path,
     )
 
-    uploaded = await upload_file_async(
-        printer.ip_address,
-        printer.access_code,
-        file_path,
-        remote_path,
-    )
+    # 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,
+            printer.ip_address,
+            printer.access_code,
+            file_path,
+            remote_path,
+            max_retries=ftp_retry_count,
+            retry_delay=ftp_retry_delay,
+            operation_name=f"Upload for reprint to {printer.name}",
+        )
+    else:
+        uploaded = await upload_file_async(
+            printer.ip_address,
+            printer.access_code,
+            file_path,
+            remote_path,
+        )
 
     if not uploaded:
         raise HTTPException(500, "Failed to upload file to printer")

+ 8 - 1
backend/app/api/routes/settings.py

@@ -72,11 +72,18 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
                 "check_updates",
                 "telemetry_enabled",
                 "virtual_printer_enabled",
+                "ftp_retry_enabled",
             ]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in ["default_filament_cost", "energy_cost_per_kwh", "ams_temp_good", "ams_temp_fair"]:
                 settings_dict[setting.key] = float(setting.value)
-            elif setting.key in ["ams_humidity_good", "ams_humidity_fair", "ams_history_retention_days"]:
+            elif setting.key in [
+                "ams_humidity_good",
+                "ams_humidity_fair",
+                "ams_history_retention_days",
+                "ftp_retry_count",
+                "ftp_retry_delay",
+            ]:
                 settings_dict[setting.key] = int(setting.value)
             elif setting.key == "default_printer_id":
                 # Handle nullable integer

+ 42 - 13
backend/app/main.py

@@ -83,7 +83,7 @@ from backend.app.core.database import async_session, init_db
 from backend.app.core.websocket import ws_manager
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.services.archive import ArchiveService
-from backend.app.services.bambu_ftp import download_file_async
+from backend.app.services.bambu_ftp import download_file_async, get_ftp_retry_settings, with_ftp_retry
 from backend.app.services.bambu_mqtt import PrinterState
 from backend.app.services.notification_service import notification_service
 from backend.app.services.print_scheduler import scheduler as print_scheduler
@@ -589,6 +589,9 @@ async def on_print_start(printer_id: int, data: dict):
         temp_path = None
         downloaded_filename = None
 
+        # Get FTP retry settings
+        ftp_retry_enabled, ftp_retry_count, ftp_retry_delay = await get_ftp_retry_settings()
+
         for try_filename in possible_names:
             if not try_filename.endswith(".3mf"):
                 continue
@@ -605,12 +608,25 @@ async def on_print_start(printer_id: int, data: dict):
             for remote_path in remote_paths:
                 logger.debug(f"Trying FTP download: {remote_path}")
                 try:
-                    if await download_file_async(
-                        printer.ip_address,
-                        printer.access_code,
-                        remote_path,
-                        temp_path,
-                    ):
+                    if ftp_retry_enabled:
+                        downloaded = await with_ftp_retry(
+                            download_file_async,
+                            printer.ip_address,
+                            printer.access_code,
+                            remote_path,
+                            temp_path,
+                            max_retries=ftp_retry_count,
+                            retry_delay=ftp_retry_delay,
+                            operation_name=f"Download 3MF from {remote_path}",
+                        )
+                    else:
+                        downloaded = await download_file_async(
+                            printer.ip_address,
+                            printer.access_code,
+                            remote_path,
+                            temp_path,
+                        )
+                    if downloaded:
                         downloaded_filename = try_filename
                         logger.info(f"Downloaded: {remote_path}")
                         break
@@ -638,12 +654,25 @@ async def on_print_start(printer_id: int, data: dict):
                         logger.info(f"Found matching file: {fname}")
                         temp_path = app_settings.archive_dir / "temp" / fname
                         temp_path.parent.mkdir(parents=True, exist_ok=True)
-                        if await download_file_async(
-                            printer.ip_address,
-                            printer.access_code,
-                            f"/cache/{fname}",
-                            temp_path,
-                        ):
+                        if ftp_retry_enabled:
+                            downloaded = await with_ftp_retry(
+                                download_file_async,
+                                printer.ip_address,
+                                printer.access_code,
+                                f"/cache/{fname}",
+                                temp_path,
+                                max_retries=ftp_retry_count,
+                                retry_delay=ftp_retry_delay,
+                                operation_name=f"Download 3MF from /cache/{fname}",
+                            )
+                        else:
+                            downloaded = await download_file_async(
+                                printer.ip_address,
+                                printer.access_code,
+                                f"/cache/{fname}",
+                                temp_path,
+                            )
+                        if downloaded:
                             downloaded_filename = fname
                             logger.info(f"Found and downloaded from cache: {fname}")
                             break

+ 8 - 0
backend/app/schemas/settings.py

@@ -68,6 +68,11 @@ class AppSettings(BaseModel):
     light_background: str = Field(default="neutral", description="Light mode background: neutral, warm, cool")
     light_accent: str = Field(default="green", description="Light mode accent: green, teal, blue, orange, purple, red")
 
+    # FTP retry settings for unreliable WiFi connections
+    ftp_retry_enabled: bool = Field(default=True, description="Enable automatic retry for FTP operations")
+    ftp_retry_count: int = Field(default=3, description="Number of retry attempts for FTP operations (1-10)")
+    ftp_retry_delay: int = Field(default=2, description="Seconds to wait between FTP retry attempts (1-30)")
+
 
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
@@ -102,3 +107,6 @@ class AppSettingsUpdate(BaseModel):
     light_style: str | None = None
     light_background: str | None = None
     light_accent: str | None = None
+    ftp_retry_enabled: bool | None = None
+    ftp_retry_count: int | None = None
+    ftp_retry_delay: int | None = None

+ 65 - 1
backend/app/services/bambu_ftp.py

@@ -3,13 +3,16 @@ import logging
 import os
 import socket
 import ssl
-from collections.abc import Callable
+from collections.abc import Awaitable, Callable
 from ftplib import FTP, FTP_TLS
 from io import BytesIO
 from pathlib import Path
+from typing import TypeVar
 
 logger = logging.getLogger(__name__)
 
+T = TypeVar("T")
+
 
 class ImplicitFTP_TLS(FTP_TLS):
     """FTP_TLS subclass for implicit FTPS (port 990) with session reuse."""
@@ -465,3 +468,64 @@ async def get_storage_info_async(
         return None
 
     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."""
+    from backend.app.api.routes.settings import get_setting
+    from backend.app.core.database import async_session
+
+    async with async_session() as db:
+        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
+
+
+async def with_ftp_retry(
+    operation: Callable[..., Awaitable[T]],
+    *args,
+    max_retries: int = 3,
+    retry_delay: float = 2.0,
+    operation_name: str = "FTP operation",
+    **kwargs,
+) -> T | None:
+    """Execute FTP operation with retry logic.
+
+    Args:
+        operation: Async function to execute
+        *args: Positional arguments for the operation
+        max_retries: Number of retry attempts (default: 3)
+        retry_delay: Seconds to wait between retries (default: 2.0)
+        operation_name: Name for logging purposes
+        **kwargs: Keyword arguments for the operation
+
+    Returns:
+        Result of the operation, or None if all attempts fail
+    """
+    last_error = None
+
+    for attempt in range(max_retries + 1):
+        try:
+            result = await operation(*args, **kwargs)
+            # Check for "falsy" success indicators
+            if result not in (False, None, []):
+                if attempt > 0:
+                    logger.info(f"{operation_name} succeeded on attempt {attempt + 1}/{max_retries + 1}")
+                return result
+            # Operation returned failure indicator
+            if attempt > 0:
+                logger.info(f"{operation_name} attempt {attempt + 1}/{max_retries + 1} returned failure")
+        except Exception as e:
+            last_error = e
+            logger.warning(f"{operation_name} attempt {attempt + 1}/{max_retries + 1} failed: {e}")
+
+        # Don't wait after the last attempt
+        if attempt < max_retries:
+            logger.info(f"{operation_name} will retry in {retry_delay}s...")
+            await asyncio.sleep(retry_delay)
+
+    logger.error(f"{operation_name} failed after {max_retries + 1} attempts")
+    if last_error:
+        logger.debug(f"Last error: {last_error}")
+    return None

+ 29 - 8
backend/app/services/firmware_update.py

@@ -18,7 +18,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.websocket import ws_manager
 from backend.app.models.printer import Printer
-from backend.app.services.bambu_ftp import get_storage_info_async, upload_file_async
+from backend.app.services.bambu_ftp import (
+    get_ftp_retry_settings,
+    get_storage_info_async,
+    upload_file_async,
+    with_ftp_retry,
+)
 from backend.app.services.firmware_check import get_firmware_service
 from backend.app.services.printer_manager import printer_manager
 
@@ -296,13 +301,29 @@ class FirmwareUpdateService:
                         state.progress = min(99, progress)  # Cap at 99 until complete
                         asyncio.run_coroutine_threadsafe(self._broadcast_progress(printer_id, state), loop)
 
-            success = await upload_file_async(
-                ip_address,
-                access_code,
-                firmware_path,
-                remote_path,
-                progress_callback=on_upload_progress,
-            )
+            # Get FTP retry settings
+            ftp_retry_enabled, ftp_retry_count, ftp_retry_delay = await get_ftp_retry_settings()
+
+            if ftp_retry_enabled:
+                success = await with_ftp_retry(
+                    upload_file_async,
+                    ip_address,
+                    access_code,
+                    firmware_path,
+                    remote_path,
+                    progress_callback=on_upload_progress,
+                    max_retries=ftp_retry_count,
+                    retry_delay=ftp_retry_delay,
+                    operation_name=f"Upload firmware to printer {printer_id}",
+                )
+            else:
+                success = await upload_file_async(
+                    ip_address,
+                    access_code,
+                    firmware_path,
+                    remote_path,
+                    progress_callback=on_upload_progress,
+                )
 
             if not success:
                 raise Exception("Failed to upload firmware to printer")

+ 22 - 7
backend/app/services/print_scheduler.py

@@ -13,7 +13,7 @@ from backend.app.models.archive import PrintArchive
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.smart_plug import SmartPlug
-from backend.app.services.bambu_ftp import upload_file_async
+from backend.app.services.bambu_ftp import get_ftp_retry_settings, upload_file_async, with_ftp_retry
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.tasmota import tasmota_service
 
@@ -271,13 +271,28 @@ class PrintScheduler:
         remote_filename = archive.filename
         remote_path = f"/cache/{remote_filename}"
 
+        # Get FTP retry settings
+        ftp_retry_enabled, ftp_retry_count, ftp_retry_delay = await get_ftp_retry_settings()
+
         try:
-            uploaded = await upload_file_async(
-                printer.ip_address,
-                printer.access_code,
-                file_path,
-                remote_path,
-            )
+            if ftp_retry_enabled:
+                uploaded = await with_ftp_retry(
+                    upload_file_async,
+                    printer.ip_address,
+                    printer.access_code,
+                    file_path,
+                    remote_path,
+                    max_retries=ftp_retry_count,
+                    retry_delay=ftp_retry_delay,
+                    operation_name=f"Upload print to {printer.name}",
+                )
+            else:
+                uploaded = await upload_file_async(
+                    printer.ip_address,
+                    printer.access_code,
+                    file_path,
+                    remote_path,
+                )
         except Exception as e:
             uploaded = False
             logger.error(f"Queue item {item.id}: FTP error: {e}")

+ 11 - 1
backend/tests/unit/services/test_printer_manager.py

@@ -364,7 +364,17 @@ class TestPrinterManager:
 
         result = manager.start_print(1, "test.gcode")
 
-        mock_client.start_print.assert_called_once_with("test.gcode", 1)
+        mock_client.start_print.assert_called_once_with(
+            "test.gcode",
+            1,
+            ams_mapping=None,
+            timelapse=False,
+            bed_levelling=True,
+            flow_cali=False,
+            vibration_cali=True,
+            layer_inspect=False,
+            use_ams=True,
+        )
         assert result is True
 
     def test_start_print_returns_false_for_unknown(self, manager):

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

@@ -564,6 +564,10 @@ export interface AppSettings {
   light_style: 'classic' | 'glow' | 'vibrant';
   light_background: 'neutral' | 'warm' | 'cool';
   light_accent: 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red';
+  // FTP retry settings
+  ftp_retry_enabled: boolean;
+  ftp_retry_count: number;
+  ftp_retry_delay: number;
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;

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

@@ -344,7 +344,10 @@ export function SettingsPage() {
       settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||
       settings.date_format !== localSettings.date_format ||
       settings.time_format !== localSettings.time_format ||
-      settings.default_printer_id !== localSettings.default_printer_id;
+      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;
 
     if (!hasChanges) {
       return;
@@ -377,6 +380,9 @@ export function SettingsPage() {
         date_format: localSettings.date_format,
         time_format: localSettings.time_format,
         default_printer_id: localSettings.default_printer_id,
+        ftp_retry_enabled: localSettings.ftp_retry_enabled,
+        ftp_retry_count: localSettings.ftp_retry_count,
+        ftp_retry_delay: localSettings.ftp_retry_delay,
       };
       updateMutation.mutate(settingsToSave);
     }, 500);
@@ -1005,6 +1011,83 @@ export function SettingsPage() {
               </div>
             </CardContent>
           </Card>
+
+          {/* FTP Retry Settings */}
+          <Card>
+            <CardHeader>
+              <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                <RefreshCw className="w-5 h-5 text-blue-400" />
+                FTP Retry
+              </h2>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <p className="text-sm text-bambu-gray">
+                Retry FTP operations when printer WiFi is unreliable. Applies to 3MF downloads, print uploads, timelapse downloads, and firmware updates.
+              </p>
+
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-white">Enable retry</p>
+                  <p className="text-sm text-bambu-gray">
+                    Automatically retry failed FTP operations
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.ftp_retry_enabled ?? true}
+                    onChange={(e) => updateSetting('ftp_retry_enabled', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+
+              {localSettings.ftp_retry_enabled && (
+                <div className="space-y-4 pt-2 border-t border-bambu-dark-tertiary">
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      Retry attempts
+                    </label>
+                    <div className="flex items-center gap-2">
+                      <input
+                        type="number"
+                        min="1"
+                        max="10"
+                        value={localSettings.ftp_retry_count ?? 3}
+                        onChange={(e) => updateSetting('ftp_retry_count', Math.min(10, Math.max(1, parseInt(e.target.value) || 3)))}
+                        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">times</span>
+                    </div>
+                    <p className="text-xs text-bambu-gray mt-1">
+                      Number of retry attempts before giving up (1-10)
+                    </p>
+                  </div>
+
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      Retry delay
+                    </label>
+                    <div className="flex items-center gap-2">
+                      <input
+                        type="number"
+                        min="1"
+                        max="30"
+                        value={localSettings.ftp_retry_delay ?? 2}
+                        onChange={(e) => updateSetting('ftp_retry_delay', Math.min(30, Math.max(1, parseInt(e.target.value) || 2)))}
+                        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">
+                      Wait time between retries (1-30)
+                    </p>
+                  </div>
+                </div>
+              )}
+            </CardContent>
+          </Card>
         </div>
 
         {/* Third Column - Updates */}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-_3zW21zb.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-OrUoRy29.js"></script>
+    <script type="module" crossorigin src="/assets/index-_3zW21zb.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