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
This commit is contained in:
maziggy
2026-01-09 09:17:02 +01:00
parent d8cecb0695
commit 0e3e37ffc1
10 changed files with 291 additions and 123 deletions
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 one or more lines are too long
+1 -1
View File
@@ -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>