Просмотр исходного кода

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 8 месяцев назад
Родитель
Сommit
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 */}

Разница между файлами не показана из-за своего большого размера
+ 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>

Некоторые файлы не были показаны из-за большого количества измененных файлов