bambu_ftp.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741
  1. import asyncio
  2. import ftplib
  3. import logging
  4. import os
  5. import socket
  6. import ssl
  7. from collections.abc import Awaitable, Callable
  8. from ftplib import FTP, FTP_TLS
  9. from io import BytesIO
  10. from pathlib import Path
  11. from typing import TypeVar
  12. logger = logging.getLogger(__name__)
  13. T = TypeVar("T")
  14. class ImplicitFTP_TLS(FTP_TLS):
  15. """FTP_TLS subclass for implicit FTPS (port 990) with model-specific SSL handling.
  16. X1C/P1S printers (vsFTPd) require SSL with session reuse on the data channel.
  17. A1/A1 Mini printers have issues with SSL on the data channel entirely and
  18. timeout waiting for transfer completion. Set skip_session_reuse=True for A1
  19. printers to skip SSL on the data channel (control channel remains encrypted).
  20. """
  21. def __init__(self, *args, skip_session_reuse: bool = False, **kwargs):
  22. super().__init__(*args, **kwargs)
  23. self._sock = None
  24. self.skip_session_reuse = skip_session_reuse
  25. self.ssl_context = ssl.create_default_context()
  26. self.ssl_context.check_hostname = False
  27. self.ssl_context.verify_mode = ssl.CERT_NONE
  28. def connect(self, host="", port=990, timeout=-999, source_address=None):
  29. """Connect to host, wrapping socket in TLS immediately (implicit FTPS)."""
  30. if host:
  31. self.host = host
  32. if port > 0:
  33. self.port = port
  34. if timeout != -999:
  35. self.timeout = timeout
  36. if source_address:
  37. self.source_address = source_address
  38. # Create and wrap socket immediately (implicit TLS)
  39. self.sock = socket.create_connection((self.host, self.port), self.timeout, source_address=self.source_address)
  40. self.sock = self.ssl_context.wrap_socket(self.sock, server_hostname=self.host)
  41. self.af = self.sock.family
  42. self.file = self.sock.makefile("r", encoding=self.encoding)
  43. self.welcome = self.getresp()
  44. return self.welcome
  45. def ntransfercmd(self, cmd, rest=None):
  46. """Override to wrap data connection in SSL for X1C/P1S only.
  47. X1C/P1S printers (vsFTPd) require SSL session reuse on the data channel.
  48. A1/A1 Mini printers have issues with SSL on the data channel entirely -
  49. they timeout waiting for the transfer completion response. For A1, we
  50. skip SSL wrapping on the data channel (control channel remains encrypted).
  51. """
  52. conn, size = FTP.ntransfercmd(self, cmd, rest)
  53. if self._prot_p and not self.skip_session_reuse:
  54. # X1C/P1S: Wrap data channel with SSL session reuse (required by vsFTPd)
  55. conn = self.ssl_context.wrap_socket(
  56. conn,
  57. server_hostname=self.host,
  58. session=self.sock.session,
  59. )
  60. # A1/A1 Mini (skip_session_reuse=True): Don't wrap data channel in SSL
  61. # The control channel remains encrypted via implicit FTPS
  62. return conn, size
  63. class BambuFTPClient:
  64. """FTP client for retrieving files from Bambu Lab printers."""
  65. FTP_PORT = 990
  66. DEFAULT_TIMEOUT = 30 # Default timeout in seconds (increased for A1 printers)
  67. # Models that need SSL session reuse disabled (A1 series has FTP issues with session reuse)
  68. # X1C/X1E/P1S/P1P/P2S use vsFTPd which requires SSL session reuse - do NOT add them here
  69. SKIP_SESSION_REUSE_MODELS = ("A1", "A1 Mini")
  70. def __init__(
  71. self,
  72. ip_address: str,
  73. access_code: str,
  74. timeout: float | None = None,
  75. printer_model: str | None = None,
  76. ):
  77. self.ip_address = ip_address
  78. self.access_code = access_code
  79. self.timeout = timeout if timeout is not None else self.DEFAULT_TIMEOUT
  80. self.printer_model = printer_model
  81. self._ftp: ImplicitFTP_TLS | None = None
  82. def _should_skip_session_reuse(self) -> bool:
  83. """Check if this printer model needs SSL session reuse disabled."""
  84. if not self.printer_model:
  85. return False
  86. return self.printer_model in self.SKIP_SESSION_REUSE_MODELS
  87. def connect(self) -> bool:
  88. """Connect to the printer FTP server (implicit FTPS on port 990)."""
  89. try:
  90. skip_reuse = self._should_skip_session_reuse()
  91. logger.debug(
  92. f"FTP connecting to {self.ip_address}:{self.FTP_PORT} "
  93. f"(timeout={self.timeout}s, model={self.printer_model}, skip_session_reuse={skip_reuse})"
  94. )
  95. self._ftp = ImplicitFTP_TLS(skip_session_reuse=skip_reuse)
  96. self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=self.timeout)
  97. logger.debug("FTP connected, logging in as bblp")
  98. self._ftp.login("bblp", self.access_code)
  99. if skip_reuse:
  100. # A1/A1 Mini: Use clear (unencrypted) data channel
  101. # These printers have issues with SSL on the data channel
  102. logger.debug("FTP logged in, setting prot_c (clear) and passive mode for A1")
  103. self._ftp.prot_c()
  104. else:
  105. # X1C/P1S/etc: Use protected (encrypted) data channel with session reuse
  106. logger.debug("FTP logged in, setting prot_p (protected) and passive mode")
  107. self._ftp.prot_p()
  108. self._ftp.set_pasv(True)
  109. # Log welcome message for debugging
  110. if hasattr(self._ftp, "welcome") and self._ftp.welcome:
  111. logger.debug(f"FTP server welcome: {self._ftp.welcome}")
  112. logger.info(f"FTP connected successfully to {self.ip_address} (model={self.printer_model})")
  113. return True
  114. except ftplib.error_perm as e:
  115. logger.warning(f"FTP connection permission error to {self.ip_address}: {e}")
  116. self._ftp = None
  117. return False
  118. except TimeoutError as e:
  119. logger.warning(f"FTP connection timed out to {self.ip_address}: {e}")
  120. self._ftp = None
  121. return False
  122. except ssl.SSLError as e:
  123. logger.warning(f"FTP SSL error connecting to {self.ip_address}: {e}")
  124. self._ftp = None
  125. return False
  126. except Exception as e:
  127. logger.warning(f"FTP connection failed to {self.ip_address}: {e} (type: {type(e).__name__})")
  128. self._ftp = None
  129. return False
  130. def disconnect(self):
  131. """Disconnect from the FTP server."""
  132. if self._ftp:
  133. try:
  134. self._ftp.quit()
  135. except Exception:
  136. pass
  137. self._ftp = None
  138. def list_files(self, path: str = "/") -> list[dict]:
  139. """List files in a directory."""
  140. if not self._ftp:
  141. return []
  142. files = []
  143. try:
  144. self._ftp.cwd(path)
  145. items = []
  146. self._ftp.retrlines("LIST", items.append)
  147. for item in items:
  148. parts = item.split()
  149. if len(parts) >= 9:
  150. name = " ".join(parts[8:])
  151. is_dir = item.startswith("d")
  152. size = int(parts[4]) if not is_dir else 0
  153. # Parse modification time from FTP listing
  154. # Format: "Nov 30 10:15" or "Nov 30 2024"
  155. mtime = None
  156. try:
  157. from datetime import datetime
  158. month = parts[5]
  159. day = parts[6]
  160. time_or_year = parts[7]
  161. # Determine if it's time (HH:MM) or year
  162. if ":" in time_or_year:
  163. # Recent file: "Nov 30 10:15" - assume current year
  164. year = datetime.now().year
  165. time_str = f"{month} {day} {year} {time_or_year}"
  166. mtime = datetime.strptime(time_str, "%b %d %Y %H:%M")
  167. # If parsed date is in the future, use last year
  168. if mtime > datetime.now():
  169. mtime = mtime.replace(year=year - 1)
  170. else:
  171. # Older file: "Nov 30 2024" - no time, just date
  172. time_str = f"{month} {day} {time_or_year}"
  173. mtime = datetime.strptime(time_str, "%b %d %Y")
  174. except (ValueError, IndexError):
  175. pass
  176. file_entry = {
  177. "name": name,
  178. "is_directory": is_dir,
  179. "size": size,
  180. "path": f"{path.rstrip('/')}/{name}",
  181. }
  182. if mtime:
  183. file_entry["mtime"] = mtime
  184. files.append(file_entry)
  185. logger.debug(f"Listed {len(files)} files in {path}")
  186. except Exception as e:
  187. logger.info(f"FTP list_files failed for {path}: {e}")
  188. return files
  189. def download_file(self, remote_path: str) -> bytes | None:
  190. """Download a file from the printer."""
  191. if not self._ftp:
  192. return None
  193. try:
  194. buffer = BytesIO()
  195. self._ftp.retrbinary(f"RETR {remote_path}", buffer.write)
  196. return buffer.getvalue()
  197. except Exception:
  198. return None
  199. def download_to_file(self, remote_path: str, local_path: Path) -> bool:
  200. """Download a file from the printer to local filesystem."""
  201. if not self._ftp:
  202. logger.warning("download_to_file called but FTP not connected")
  203. return False
  204. try:
  205. local_path.parent.mkdir(parents=True, exist_ok=True)
  206. with open(local_path, "wb") as f:
  207. self._ftp.retrbinary(f"RETR {remote_path}", f.write)
  208. f.flush()
  209. os.fsync(f.fileno())
  210. file_size = local_path.stat().st_size if local_path.exists() else 0
  211. logger.info(f"Successfully downloaded {remote_path} to {local_path} ({file_size} bytes)")
  212. return True
  213. except Exception as e:
  214. # Log at INFO level so we can see failures in normal logs
  215. logger.info(f"FTP download failed for {remote_path}: {e}")
  216. # Clean up partial file if it exists
  217. if local_path.exists():
  218. try:
  219. local_path.unlink()
  220. except Exception:
  221. pass
  222. return False
  223. def diagnose_storage(self) -> dict:
  224. """Run storage diagnostics and return results. For debugging upload issues."""
  225. results = {
  226. "connected": self._ftp is not None,
  227. "can_list_root": False,
  228. "root_files": [],
  229. "can_list_cache": False,
  230. "storage_info": None,
  231. "pwd": None,
  232. "errors": [],
  233. }
  234. if not self._ftp:
  235. results["errors"].append("FTP not connected")
  236. return results
  237. # Try to get current directory
  238. try:
  239. results["pwd"] = self._ftp.pwd()
  240. logger.debug(f"FTP current directory: {results['pwd']}")
  241. except Exception as e:
  242. results["errors"].append(f"PWD failed: {e}")
  243. logger.debug(f"FTP PWD failed: {e}")
  244. # Try to list root directory
  245. try:
  246. self._ftp.cwd("/")
  247. items = []
  248. self._ftp.retrlines("LIST", items.append)
  249. results["can_list_root"] = True
  250. results["root_files"] = items[:10] # First 10 entries
  251. logger.debug(f"FTP root listing ({len(items)} items): {items[:5]}")
  252. except Exception as e:
  253. results["errors"].append(f"LIST / failed: {e}")
  254. logger.debug(f"FTP LIST / failed: {e}")
  255. # Try to list /cache (should exist on all printers)
  256. try:
  257. self._ftp.cwd("/cache")
  258. items = []
  259. self._ftp.retrlines("LIST", items.append)
  260. results["can_list_cache"] = True
  261. logger.debug(f"FTP /cache listing: {len(items)} items")
  262. except Exception as e:
  263. results["errors"].append(f"LIST /cache failed: {e}")
  264. logger.debug(f"FTP LIST /cache failed: {e}")
  265. # Try to get storage info
  266. try:
  267. results["storage_info"] = self.get_storage_info()
  268. logger.debug(f"FTP storage info: {results['storage_info']}")
  269. except Exception as e:
  270. results["errors"].append(f"Storage info failed: {e}")
  271. return results
  272. def upload_file(
  273. self,
  274. local_path: Path,
  275. remote_path: str,
  276. progress_callback: Callable[[int, int], None] | None = None,
  277. ) -> bool:
  278. """Upload a file to the printer with optional progress callback."""
  279. if not self._ftp:
  280. logger.warning("upload_file: FTP not connected")
  281. return False
  282. try:
  283. file_size = local_path.stat().st_size if local_path.exists() else 0
  284. logger.info(f"FTP uploading {local_path} ({file_size} bytes) to {remote_path}")
  285. # Run storage diagnostics before upload (debug)
  286. logger.debug("Running pre-upload storage diagnostics...")
  287. diag = self.diagnose_storage()
  288. logger.info(
  289. f"FTP storage diagnostics: can_list_root={diag['can_list_root']}, "
  290. f"can_list_cache={diag['can_list_cache']}, "
  291. f"storage={diag['storage_info']}, errors={diag['errors']}"
  292. )
  293. if diag["root_files"]:
  294. logger.debug(f"FTP root directory contents: {diag['root_files']}")
  295. uploaded = 0
  296. def on_block(block: bytes):
  297. nonlocal uploaded
  298. uploaded += len(block)
  299. if progress_callback:
  300. progress_callback(uploaded, file_size)
  301. with open(local_path, "rb") as f:
  302. logger.debug(f"FTP STOR command starting for {remote_path}")
  303. self._ftp.storbinary(f"STOR {remote_path}", f, callback=on_block)
  304. logger.info(f"FTP upload complete: {remote_path}")
  305. return True
  306. except ftplib.error_perm as e:
  307. # Permanent FTP error (4xx/5xx response)
  308. error_code = str(e)[:3] if str(e) else "unknown"
  309. logger.error(f"FTP upload failed for {remote_path}: {e} (error code: {error_code})")
  310. if error_code == "553":
  311. logger.error(
  312. "FTP 553 error - Could not create file. Possible causes: "
  313. "1) No SD card inserted, 2) SD card full, 3) SD card not formatted correctly (needs FAT32/exFAT), "
  314. "4) Printer busy/not ready, 5) File path issue"
  315. )
  316. elif error_code == "550":
  317. logger.error("FTP 550 error - File/directory not found or permission denied")
  318. elif error_code == "552":
  319. logger.error("FTP 552 error - Storage quota exceeded (SD card full?)")
  320. return False
  321. except Exception as e:
  322. logger.error(f"FTP upload failed for {remote_path}: {e} (type: {type(e).__name__})")
  323. return False
  324. def upload_bytes(self, data: bytes, remote_path: str) -> bool:
  325. """Upload bytes to the printer."""
  326. if not self._ftp:
  327. return False
  328. try:
  329. buffer = BytesIO(data)
  330. self._ftp.storbinary(f"STOR {remote_path}", buffer)
  331. return True
  332. except Exception:
  333. return False
  334. def delete_file(self, remote_path: str) -> bool:
  335. """Delete a file from the printer."""
  336. if not self._ftp:
  337. return False
  338. try:
  339. self._ftp.delete(remote_path)
  340. return True
  341. except Exception as e:
  342. logger.warning(f"Failed to delete {remote_path}: {e}")
  343. return False
  344. def get_file_size(self, remote_path: str) -> int | None:
  345. """Get the size of a file."""
  346. if not self._ftp:
  347. return None
  348. try:
  349. return self._ftp.size(remote_path)
  350. except Exception:
  351. return None
  352. def get_storage_info(self) -> dict | None:
  353. """Get storage information from the printer."""
  354. if not self._ftp:
  355. return None
  356. result = {}
  357. # Try AVBL command (available space) - some FTP servers support this
  358. try:
  359. response = self._ftp.sendcmd("AVBL")
  360. logger.debug(f"AVBL response: {response}")
  361. # Response format: "213 <bytes available>"
  362. if response.startswith("213"):
  363. parts = response.split()
  364. if len(parts) >= 2:
  365. result["free_bytes"] = int(parts[1])
  366. except Exception as e:
  367. logger.debug(f"AVBL command not supported: {e}")
  368. # Try STAT command as fallback
  369. try:
  370. response = self._ftp.sendcmd("STAT")
  371. logger.debug(f"STAT response: {response}")
  372. except Exception:
  373. pass
  374. # Calculate used space by listing root directories
  375. try:
  376. total_used = 0
  377. dirs_to_scan = ["/cache", "/timelapse", "/model", "/data", "/data/Metadata", "/"]
  378. for dir_path in dirs_to_scan:
  379. try:
  380. self._ftp.cwd(dir_path)
  381. items = []
  382. self._ftp.retrlines("LIST", items.append)
  383. for item in items:
  384. parts = item.split()
  385. if len(parts) >= 5 and not item.startswith("d"):
  386. try:
  387. total_used += int(parts[4])
  388. except ValueError:
  389. pass
  390. except Exception:
  391. pass
  392. result["used_bytes"] = total_used
  393. except Exception:
  394. pass
  395. return result if result else None
  396. async def download_file_async(
  397. ip_address: str,
  398. access_code: str,
  399. remote_path: str,
  400. local_path: Path,
  401. timeout: float = 60.0,
  402. socket_timeout: float | None = None,
  403. printer_model: str | None = None,
  404. ) -> bool:
  405. """Async wrapper for downloading a file with timeout.
  406. Args:
  407. ip_address: Printer IP address
  408. access_code: Printer access code
  409. remote_path: Remote file path on printer
  410. local_path: Local path to save file
  411. timeout: Overall operation timeout (asyncio)
  412. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  413. printer_model: Printer model for A1-specific workarounds
  414. """
  415. loop = asyncio.get_event_loop()
  416. def _download():
  417. client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
  418. if client.connect():
  419. try:
  420. return client.download_to_file(remote_path, local_path)
  421. finally:
  422. client.disconnect()
  423. return False
  424. try:
  425. return await asyncio.wait_for(loop.run_in_executor(None, _download), timeout=timeout)
  426. except TimeoutError:
  427. logger.warning(f"FTP download timed out after {timeout}s for {remote_path}")
  428. return False
  429. async def download_file_try_paths_async(
  430. ip_address: str,
  431. access_code: str,
  432. remote_paths: list[str],
  433. local_path: Path,
  434. socket_timeout: float | None = None,
  435. printer_model: str | None = None,
  436. ) -> bool:
  437. """Try downloading a file from multiple paths using a single connection.
  438. Args:
  439. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  440. printer_model: Printer model for A1-specific workarounds
  441. """
  442. loop = asyncio.get_event_loop()
  443. def _download():
  444. client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
  445. if not client.connect():
  446. return False
  447. try:
  448. return any(client.download_to_file(remote_path, local_path) for remote_path in remote_paths)
  449. finally:
  450. client.disconnect()
  451. return await loop.run_in_executor(None, _download)
  452. async def upload_file_async(
  453. ip_address: str,
  454. access_code: str,
  455. local_path: Path,
  456. remote_path: str,
  457. timeout: float = 600.0,
  458. progress_callback: Callable[[int, int], None] | None = None,
  459. socket_timeout: float | None = None,
  460. printer_model: str | None = None,
  461. ) -> bool:
  462. """Async wrapper for uploading a file with timeout and progress callback.
  463. Args:
  464. ip_address: Printer IP address
  465. access_code: Printer access code
  466. local_path: Local file path to upload
  467. remote_path: Remote path on printer
  468. timeout: Overall operation timeout (asyncio)
  469. progress_callback: Optional callback for progress updates
  470. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  471. printer_model: Printer model for A1-specific workarounds
  472. """
  473. loop = asyncio.get_event_loop()
  474. def _upload():
  475. logger.info(
  476. f"FTP connecting to {ip_address} for upload (model={printer_model}, socket_timeout={socket_timeout}s)..."
  477. )
  478. client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
  479. if client.connect():
  480. logger.info(f"FTP connected to {ip_address}")
  481. try:
  482. return client.upload_file(local_path, remote_path, progress_callback)
  483. finally:
  484. client.disconnect()
  485. logger.warning(f"FTP connection failed to {ip_address}")
  486. return False
  487. try:
  488. return await asyncio.wait_for(loop.run_in_executor(None, _upload), timeout=timeout)
  489. except TimeoutError:
  490. logger.warning(f"FTP upload timed out after {timeout}s for {remote_path}")
  491. return False
  492. async def list_files_async(
  493. ip_address: str,
  494. access_code: str,
  495. path: str = "/",
  496. timeout: float = 30.0,
  497. socket_timeout: float | None = None,
  498. printer_model: str | None = None,
  499. ) -> list[dict]:
  500. """Async wrapper for listing files with timeout.
  501. Args:
  502. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  503. printer_model: Printer model for A1-specific workarounds
  504. """
  505. loop = asyncio.get_event_loop()
  506. def _list():
  507. client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
  508. if client.connect():
  509. try:
  510. return client.list_files(path)
  511. finally:
  512. client.disconnect()
  513. return []
  514. try:
  515. return await asyncio.wait_for(loop.run_in_executor(None, _list), timeout=timeout)
  516. except TimeoutError:
  517. logger.warning(f"FTP list_files timed out after {timeout}s for {path}")
  518. return []
  519. async def delete_file_async(
  520. ip_address: str,
  521. access_code: str,
  522. remote_path: str,
  523. socket_timeout: float | None = None,
  524. printer_model: str | None = None,
  525. ) -> bool:
  526. """Async wrapper for deleting a file.
  527. Args:
  528. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  529. printer_model: Printer model for A1-specific workarounds
  530. """
  531. loop = asyncio.get_event_loop()
  532. def _delete():
  533. client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
  534. if client.connect():
  535. try:
  536. return client.delete_file(remote_path)
  537. finally:
  538. client.disconnect()
  539. return False
  540. return await loop.run_in_executor(None, _delete)
  541. async def download_file_bytes_async(
  542. ip_address: str,
  543. access_code: str,
  544. remote_path: str,
  545. socket_timeout: float | None = None,
  546. printer_model: str | None = None,
  547. ) -> bytes | None:
  548. """Async wrapper for downloading file as bytes.
  549. Args:
  550. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  551. printer_model: Printer model for A1-specific workarounds
  552. """
  553. loop = asyncio.get_event_loop()
  554. def _download():
  555. client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
  556. if client.connect():
  557. try:
  558. return client.download_file(remote_path)
  559. finally:
  560. client.disconnect()
  561. return None
  562. return await loop.run_in_executor(None, _download)
  563. async def get_storage_info_async(
  564. ip_address: str,
  565. access_code: str,
  566. socket_timeout: float | None = None,
  567. printer_model: str | None = None,
  568. ) -> dict | None:
  569. """Async wrapper for getting storage info.
  570. Args:
  571. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  572. printer_model: Printer model for A1-specific workarounds
  573. """
  574. loop = asyncio.get_event_loop()
  575. def _get_storage():
  576. client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
  577. if client.connect():
  578. try:
  579. return client.get_storage_info()
  580. finally:
  581. client.disconnect()
  582. return None
  583. return await loop.run_in_executor(None, _get_storage)
  584. async def get_ftp_retry_settings() -> tuple[bool, int, float, float]:
  585. """Get FTP retry settings from database.
  586. Returns:
  587. Tuple of (retry_enabled, retry_count, retry_delay, timeout)
  588. """
  589. from backend.app.api.routes.settings import get_setting
  590. from backend.app.core.database import async_session
  591. async with async_session() as db:
  592. enabled = (await get_setting(db, "ftp_retry_enabled") or "true") == "true"
  593. count = int(await get_setting(db, "ftp_retry_count") or "3")
  594. delay = float(await get_setting(db, "ftp_retry_delay") or "2")
  595. timeout = float(await get_setting(db, "ftp_timeout") or "30")
  596. return enabled, count, delay, timeout
  597. async def with_ftp_retry(
  598. operation: Callable[..., Awaitable[T]],
  599. *args,
  600. max_retries: int = 3,
  601. retry_delay: float = 2.0,
  602. operation_name: str = "FTP operation",
  603. **kwargs,
  604. ) -> T | None:
  605. """Execute FTP operation with retry logic.
  606. Args:
  607. operation: Async function to execute
  608. *args: Positional arguments for the operation
  609. max_retries: Number of retry attempts (default: 3)
  610. retry_delay: Seconds to wait between retries (default: 2.0)
  611. operation_name: Name for logging purposes
  612. **kwargs: Keyword arguments for the operation
  613. Returns:
  614. Result of the operation, or None if all attempts fail
  615. """
  616. last_error = None
  617. for attempt in range(max_retries + 1):
  618. try:
  619. result = await operation(*args, **kwargs)
  620. # Check for "falsy" success indicators
  621. if result not in (False, None, []):
  622. if attempt > 0:
  623. logger.info(f"{operation_name} succeeded on attempt {attempt + 1}/{max_retries + 1}")
  624. return result
  625. # Operation returned failure indicator
  626. if attempt > 0:
  627. logger.info(f"{operation_name} attempt {attempt + 1}/{max_retries + 1} returned failure")
  628. except Exception as e:
  629. last_error = e
  630. logger.warning(f"{operation_name} attempt {attempt + 1}/{max_retries + 1} failed: {e}")
  631. # Don't wait after the last attempt
  632. if attempt < max_retries:
  633. logger.info(f"{operation_name} will retry in {retry_delay}s...")
  634. await asyncio.sleep(retry_delay)
  635. logger.error(f"{operation_name} failed after {max_retries + 1} attempts")
  636. if last_error:
  637. logger.debug(f"Last error: {last_error}")
  638. return None