bambu_ftp.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835
  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 may need SSL mode fallback (try prot_p first, fall back to prot_c)
  68. # These models have varying FTP SSL behavior depending on firmware version
  69. A1_MODELS = ("A1", "A1 Mini")
  70. # Cache for working FTP modes per printer IP
  71. # Maps IP -> "prot_p" or "prot_c"
  72. _mode_cache: dict[str, str] = {}
  73. def __init__(
  74. self,
  75. ip_address: str,
  76. access_code: str,
  77. timeout: float | None = None,
  78. printer_model: str | None = None,
  79. force_prot_c: bool = False,
  80. ):
  81. self.ip_address = ip_address
  82. self.access_code = access_code
  83. self.timeout = timeout if timeout is not None else self.DEFAULT_TIMEOUT
  84. self.printer_model = printer_model
  85. self.force_prot_c = force_prot_c
  86. self._ftp: ImplicitFTP_TLS | None = None
  87. def _is_a1_model(self) -> bool:
  88. """Check if this is an A1 series printer."""
  89. if not self.printer_model:
  90. return False
  91. return self.printer_model in self.A1_MODELS
  92. def _get_cached_mode(self) -> str | None:
  93. """Get cached FTP mode for this printer."""
  94. return self._mode_cache.get(self.ip_address)
  95. @classmethod
  96. def cache_mode(cls, ip_address: str, mode: str):
  97. """Cache the working FTP mode for a printer."""
  98. cls._mode_cache[ip_address] = mode
  99. logger.info(f"FTP mode cached for {ip_address}: {mode}")
  100. def _should_use_prot_c(self) -> bool:
  101. """Determine if we should use prot_c (clear) mode."""
  102. # If explicitly forced, use prot_c
  103. if self.force_prot_c:
  104. return True
  105. # Check cache first
  106. cached = self._get_cached_mode()
  107. if cached:
  108. return cached == "prot_c"
  109. # Default: try prot_p first (will fall back if needed)
  110. return False
  111. def connect(self) -> bool:
  112. """Connect to the printer FTP server (implicit FTPS on port 990)."""
  113. try:
  114. use_prot_c = self._should_use_prot_c()
  115. logger.debug(
  116. f"FTP connecting to {self.ip_address}:{self.FTP_PORT} "
  117. f"(timeout={self.timeout}s, model={self.printer_model}, prot_c={use_prot_c})"
  118. )
  119. self._ftp = ImplicitFTP_TLS(skip_session_reuse=use_prot_c)
  120. self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=self.timeout)
  121. logger.debug("FTP connected, logging in as bblp")
  122. self._ftp.login("bblp", self.access_code)
  123. if use_prot_c:
  124. # Use clear (unencrypted) data channel
  125. logger.debug("FTP logged in, setting prot_c (clear) and passive mode")
  126. self._ftp.prot_c()
  127. else:
  128. # Use protected (encrypted) data channel with session reuse
  129. logger.debug("FTP logged in, setting prot_p (protected) and passive mode")
  130. self._ftp.prot_p()
  131. self._ftp.set_pasv(True)
  132. # Log welcome message for debugging
  133. if hasattr(self._ftp, "welcome") and self._ftp.welcome:
  134. logger.debug(f"FTP server welcome: {self._ftp.welcome}")
  135. logger.info(
  136. f"FTP connected successfully to {self.ip_address} (model={self.printer_model}, prot_c={use_prot_c})"
  137. )
  138. return True
  139. except ftplib.error_perm as e:
  140. logger.warning(f"FTP connection permission error to {self.ip_address}: {e}")
  141. self._ftp = None
  142. return False
  143. except TimeoutError as e:
  144. logger.warning(f"FTP connection timed out to {self.ip_address}: {e}")
  145. self._ftp = None
  146. return False
  147. except ssl.SSLError as e:
  148. logger.warning(f"FTP SSL error connecting to {self.ip_address}: {e}")
  149. self._ftp = None
  150. return False
  151. except Exception as e:
  152. logger.warning(f"FTP connection failed to {self.ip_address}: {e} (type: {type(e).__name__})")
  153. self._ftp = None
  154. return False
  155. def disconnect(self):
  156. """Disconnect from the FTP server."""
  157. if self._ftp:
  158. try:
  159. self._ftp.quit()
  160. except Exception:
  161. pass
  162. self._ftp = None
  163. def list_files(self, path: str = "/") -> list[dict]:
  164. """List files in a directory."""
  165. if not self._ftp:
  166. return []
  167. files = []
  168. try:
  169. self._ftp.cwd(path)
  170. items = []
  171. self._ftp.retrlines("LIST", items.append)
  172. for item in items:
  173. parts = item.split()
  174. if len(parts) >= 9:
  175. name = " ".join(parts[8:])
  176. is_dir = item.startswith("d")
  177. size = int(parts[4]) if not is_dir else 0
  178. # Parse modification time from FTP listing
  179. # Format: "Nov 30 10:15" or "Nov 30 2024"
  180. mtime = None
  181. try:
  182. from datetime import datetime
  183. month = parts[5]
  184. day = parts[6]
  185. time_or_year = parts[7]
  186. # Determine if it's time (HH:MM) or year
  187. if ":" in time_or_year:
  188. # Recent file: "Nov 30 10:15" - assume current year
  189. year = datetime.now().year
  190. time_str = f"{month} {day} {year} {time_or_year}"
  191. mtime = datetime.strptime(time_str, "%b %d %Y %H:%M")
  192. # If parsed date is in the future, use last year
  193. if mtime > datetime.now():
  194. mtime = mtime.replace(year=year - 1)
  195. else:
  196. # Older file: "Nov 30 2024" - no time, just date
  197. time_str = f"{month} {day} {time_or_year}"
  198. mtime = datetime.strptime(time_str, "%b %d %Y")
  199. except (ValueError, IndexError):
  200. pass
  201. file_entry = {
  202. "name": name,
  203. "is_directory": is_dir,
  204. "size": size,
  205. "path": f"{path.rstrip('/')}/{name}",
  206. }
  207. if mtime:
  208. file_entry["mtime"] = mtime
  209. files.append(file_entry)
  210. logger.debug(f"Listed {len(files)} files in {path}")
  211. except Exception as e:
  212. logger.info(f"FTP list_files failed for {path}: {e}")
  213. return files
  214. def download_file(self, remote_path: str) -> bytes | None:
  215. """Download a file from the printer."""
  216. if not self._ftp:
  217. return None
  218. try:
  219. buffer = BytesIO()
  220. self._ftp.retrbinary(f"RETR {remote_path}", buffer.write)
  221. return buffer.getvalue()
  222. except Exception:
  223. return None
  224. def download_to_file(self, remote_path: str, local_path: Path) -> bool:
  225. """Download a file from the printer to local filesystem."""
  226. if not self._ftp:
  227. logger.warning("download_to_file called but FTP not connected")
  228. return False
  229. try:
  230. local_path.parent.mkdir(parents=True, exist_ok=True)
  231. with open(local_path, "wb") as f:
  232. self._ftp.retrbinary(f"RETR {remote_path}", f.write)
  233. f.flush()
  234. os.fsync(f.fileno())
  235. file_size = local_path.stat().st_size if local_path.exists() else 0
  236. logger.info(f"Successfully downloaded {remote_path} to {local_path} ({file_size} bytes)")
  237. return True
  238. except Exception as e:
  239. # Log at INFO level so we can see failures in normal logs
  240. logger.info(f"FTP download failed for {remote_path}: {e}")
  241. # Clean up partial file if it exists
  242. if local_path.exists():
  243. try:
  244. local_path.unlink()
  245. except Exception:
  246. pass
  247. return False
  248. def diagnose_storage(self) -> dict:
  249. """Run storage diagnostics and return results. For debugging upload issues."""
  250. results = {
  251. "connected": self._ftp is not None,
  252. "can_list_root": False,
  253. "root_files": [],
  254. "can_list_cache": False,
  255. "storage_info": None,
  256. "pwd": None,
  257. "errors": [],
  258. }
  259. if not self._ftp:
  260. results["errors"].append("FTP not connected")
  261. return results
  262. # Try to get current directory
  263. try:
  264. results["pwd"] = self._ftp.pwd()
  265. logger.debug(f"FTP current directory: {results['pwd']}")
  266. except Exception as e:
  267. results["errors"].append(f"PWD failed: {e}")
  268. logger.debug(f"FTP PWD failed: {e}")
  269. # Try to list root directory
  270. try:
  271. self._ftp.cwd("/")
  272. items = []
  273. self._ftp.retrlines("LIST", items.append)
  274. results["can_list_root"] = True
  275. results["root_files"] = items[:10] # First 10 entries
  276. logger.debug(f"FTP root listing ({len(items)} items): {items[:5]}")
  277. except Exception as e:
  278. results["errors"].append(f"LIST / failed: {e}")
  279. logger.debug(f"FTP LIST / failed: {e}")
  280. # Try to list /cache (should exist on all printers)
  281. try:
  282. self._ftp.cwd("/cache")
  283. items = []
  284. self._ftp.retrlines("LIST", items.append)
  285. results["can_list_cache"] = True
  286. logger.debug(f"FTP /cache listing: {len(items)} items")
  287. except Exception as e:
  288. results["errors"].append(f"LIST /cache failed: {e}")
  289. logger.debug(f"FTP LIST /cache failed: {e}")
  290. # Try to get storage info
  291. try:
  292. results["storage_info"] = self.get_storage_info()
  293. logger.debug(f"FTP storage info: {results['storage_info']}")
  294. except Exception as e:
  295. results["errors"].append(f"Storage info failed: {e}")
  296. return results
  297. def upload_file(
  298. self,
  299. local_path: Path,
  300. remote_path: str,
  301. progress_callback: Callable[[int, int], None] | None = None,
  302. ) -> bool:
  303. """Upload a file to the printer with optional progress callback."""
  304. if not self._ftp:
  305. logger.warning("upload_file: FTP not connected")
  306. return False
  307. try:
  308. file_size = local_path.stat().st_size if local_path.exists() else 0
  309. logger.info(f"FTP uploading {local_path} ({file_size} bytes) to {remote_path}")
  310. # Run storage diagnostics before upload (debug)
  311. logger.debug("Running pre-upload storage diagnostics...")
  312. diag = self.diagnose_storage()
  313. logger.info(
  314. f"FTP storage diagnostics: can_list_root={diag['can_list_root']}, "
  315. f"can_list_cache={diag['can_list_cache']}, "
  316. f"storage={diag['storage_info']}, errors={diag['errors']}"
  317. )
  318. if diag["root_files"]:
  319. logger.debug(f"FTP root directory contents: {diag['root_files']}")
  320. uploaded = 0
  321. def on_block(block: bytes):
  322. nonlocal uploaded
  323. uploaded += len(block)
  324. if progress_callback:
  325. progress_callback(uploaded, file_size)
  326. with open(local_path, "rb") as f:
  327. logger.debug(f"FTP STOR command starting for {remote_path}")
  328. self._ftp.storbinary(f"STOR {remote_path}", f, callback=on_block)
  329. logger.info(f"FTP upload complete: {remote_path}")
  330. return True
  331. except ftplib.error_perm as e:
  332. # Permanent FTP error (4xx/5xx response)
  333. error_code = str(e)[:3] if str(e) else "unknown"
  334. logger.error(f"FTP upload failed for {remote_path}: {e} (error code: {error_code})")
  335. if error_code == "553":
  336. logger.error(
  337. "FTP 553 error - Could not create file. Possible causes: "
  338. "1) No SD card inserted, 2) SD card full, 3) SD card not formatted correctly (needs FAT32/exFAT), "
  339. "4) Printer busy/not ready, 5) File path issue"
  340. )
  341. elif error_code == "550":
  342. logger.error("FTP 550 error - File/directory not found or permission denied")
  343. elif error_code == "552":
  344. logger.error("FTP 552 error - Storage quota exceeded (SD card full?)")
  345. return False
  346. except Exception as e:
  347. logger.error(f"FTP upload failed for {remote_path}: {e} (type: {type(e).__name__})")
  348. return False
  349. def upload_bytes(self, data: bytes, remote_path: str) -> bool:
  350. """Upload bytes to the printer."""
  351. if not self._ftp:
  352. return False
  353. try:
  354. buffer = BytesIO(data)
  355. self._ftp.storbinary(f"STOR {remote_path}", buffer)
  356. return True
  357. except Exception:
  358. return False
  359. def delete_file(self, remote_path: str) -> bool:
  360. """Delete a file from the printer."""
  361. if not self._ftp:
  362. return False
  363. try:
  364. self._ftp.delete(remote_path)
  365. return True
  366. except Exception as e:
  367. logger.warning(f"Failed to delete {remote_path}: {e}")
  368. return False
  369. def get_file_size(self, remote_path: str) -> int | None:
  370. """Get the size of a file."""
  371. if not self._ftp:
  372. return None
  373. try:
  374. return self._ftp.size(remote_path)
  375. except Exception:
  376. return None
  377. def get_storage_info(self) -> dict | None:
  378. """Get storage information from the printer."""
  379. if not self._ftp:
  380. return None
  381. result = {}
  382. # Try AVBL command (available space) - some FTP servers support this
  383. try:
  384. response = self._ftp.sendcmd("AVBL")
  385. logger.debug(f"AVBL response: {response}")
  386. # Response format: "213 <bytes available>"
  387. if response.startswith("213"):
  388. parts = response.split()
  389. if len(parts) >= 2:
  390. result["free_bytes"] = int(parts[1])
  391. except Exception as e:
  392. logger.debug(f"AVBL command not supported: {e}")
  393. # Try STAT command as fallback
  394. try:
  395. response = self._ftp.sendcmd("STAT")
  396. logger.debug(f"STAT response: {response}")
  397. except Exception:
  398. pass
  399. # Calculate used space by listing root directories
  400. try:
  401. total_used = 0
  402. dirs_to_scan = ["/cache", "/timelapse", "/model", "/data", "/data/Metadata", "/"]
  403. for dir_path in dirs_to_scan:
  404. try:
  405. self._ftp.cwd(dir_path)
  406. items = []
  407. self._ftp.retrlines("LIST", items.append)
  408. for item in items:
  409. parts = item.split()
  410. if len(parts) >= 5 and not item.startswith("d"):
  411. try:
  412. total_used += int(parts[4])
  413. except ValueError:
  414. pass
  415. except Exception:
  416. pass
  417. result["used_bytes"] = total_used
  418. except Exception:
  419. pass
  420. return result if result else None
  421. async def download_file_async(
  422. ip_address: str,
  423. access_code: str,
  424. remote_path: str,
  425. local_path: Path,
  426. timeout: float = 60.0,
  427. socket_timeout: float | None = None,
  428. printer_model: str | None = None,
  429. ) -> bool:
  430. """Async wrapper for downloading a file with timeout.
  431. For A1/A1 Mini printers, automatically tries prot_p first, then falls back
  432. to prot_c if the download fails. The working mode is cached for future operations.
  433. Args:
  434. ip_address: Printer IP address
  435. access_code: Printer access code
  436. remote_path: Remote file path on printer
  437. local_path: Local path to save file
  438. timeout: Overall operation timeout (asyncio)
  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. is_a1 = printer_model in BambuFTPClient.A1_MODELS if printer_model else False
  444. def _download(force_prot_c: bool = False) -> bool:
  445. mode_str = "prot_c" if force_prot_c else "prot_p"
  446. client = BambuFTPClient(
  447. ip_address, access_code, timeout=socket_timeout, printer_model=printer_model, force_prot_c=force_prot_c
  448. )
  449. if client.connect():
  450. try:
  451. result = client.download_to_file(remote_path, local_path)
  452. if result:
  453. # Cache the working mode
  454. BambuFTPClient.cache_mode(ip_address, mode_str)
  455. return result
  456. finally:
  457. client.disconnect()
  458. return False
  459. try:
  460. # Check if we have a cached mode for this printer
  461. cached_mode = BambuFTPClient._mode_cache.get(ip_address)
  462. if cached_mode:
  463. # Use cached mode
  464. force_prot_c = cached_mode == "prot_c"
  465. return await asyncio.wait_for(loop.run_in_executor(None, lambda: _download(force_prot_c)), timeout=timeout)
  466. # No cached mode - try prot_p first
  467. result = await asyncio.wait_for(loop.run_in_executor(None, lambda: _download(False)), timeout=timeout)
  468. if result:
  469. return True
  470. # Download failed - for A1 models, try prot_c fallback
  471. if is_a1:
  472. logger.info("FTP download failed with prot_p for A1 model, trying prot_c fallback...")
  473. result = await asyncio.wait_for(loop.run_in_executor(None, lambda: _download(True)), timeout=timeout)
  474. return result
  475. return False
  476. except TimeoutError:
  477. logger.warning(f"FTP download timed out after {timeout}s for {remote_path}")
  478. return False
  479. async def download_file_try_paths_async(
  480. ip_address: str,
  481. access_code: str,
  482. remote_paths: list[str],
  483. local_path: Path,
  484. socket_timeout: float | None = None,
  485. printer_model: str | None = None,
  486. ) -> bool:
  487. """Try downloading a file from multiple paths using a single connection.
  488. Args:
  489. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  490. printer_model: Printer model for A1-specific workarounds
  491. """
  492. loop = asyncio.get_event_loop()
  493. def _download():
  494. client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
  495. if not client.connect():
  496. return False
  497. try:
  498. return any(client.download_to_file(remote_path, local_path) for remote_path in remote_paths)
  499. finally:
  500. client.disconnect()
  501. return await loop.run_in_executor(None, _download)
  502. async def upload_file_async(
  503. ip_address: str,
  504. access_code: str,
  505. local_path: Path,
  506. remote_path: str,
  507. timeout: float = 600.0,
  508. progress_callback: Callable[[int, int], None] | None = None,
  509. socket_timeout: float | None = None,
  510. printer_model: str | None = None,
  511. ) -> bool:
  512. """Async wrapper for uploading a file with timeout and progress callback.
  513. For A1/A1 Mini printers, automatically tries prot_p first, then falls back
  514. to prot_c if the upload fails. The working mode is cached for future uploads.
  515. Args:
  516. ip_address: Printer IP address
  517. access_code: Printer access code
  518. local_path: Local file path to upload
  519. remote_path: Remote path on printer
  520. timeout: Overall operation timeout (asyncio)
  521. progress_callback: Optional callback for progress updates
  522. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  523. printer_model: Printer model for A1-specific workarounds
  524. """
  525. loop = asyncio.get_event_loop()
  526. is_a1 = printer_model in BambuFTPClient.A1_MODELS if printer_model else False
  527. def _upload(force_prot_c: bool = False) -> bool:
  528. mode_str = "prot_c" if force_prot_c else "prot_p"
  529. logger.info(
  530. f"FTP connecting to {ip_address} for upload (model={printer_model}, "
  531. f"mode={mode_str}, socket_timeout={socket_timeout}s)..."
  532. )
  533. client = BambuFTPClient(
  534. ip_address, access_code, timeout=socket_timeout, printer_model=printer_model, force_prot_c=force_prot_c
  535. )
  536. if client.connect():
  537. logger.info(f"FTP connected to {ip_address}")
  538. try:
  539. result = client.upload_file(local_path, remote_path, progress_callback)
  540. if result:
  541. # Cache the working mode
  542. BambuFTPClient.cache_mode(ip_address, mode_str)
  543. return result
  544. finally:
  545. client.disconnect()
  546. logger.warning(f"FTP connection failed to {ip_address}")
  547. return False
  548. try:
  549. # Check if we have a cached mode for this printer
  550. cached_mode = BambuFTPClient._mode_cache.get(ip_address)
  551. if cached_mode:
  552. # Use cached mode
  553. force_prot_c = cached_mode == "prot_c"
  554. return await asyncio.wait_for(loop.run_in_executor(None, lambda: _upload(force_prot_c)), timeout=timeout)
  555. # No cached mode - try prot_p first
  556. result = await asyncio.wait_for(loop.run_in_executor(None, lambda: _upload(False)), timeout=timeout)
  557. if result:
  558. return True
  559. # Upload failed - for A1 models, try prot_c fallback
  560. if is_a1:
  561. logger.info("FTP upload failed with prot_p for A1 model, trying prot_c fallback...")
  562. result = await asyncio.wait_for(loop.run_in_executor(None, lambda: _upload(True)), timeout=timeout)
  563. return result
  564. return False
  565. except TimeoutError:
  566. logger.warning(f"FTP upload timed out after {timeout}s for {remote_path}")
  567. return False
  568. async def list_files_async(
  569. ip_address: str,
  570. access_code: str,
  571. path: str = "/",
  572. timeout: float = 30.0,
  573. socket_timeout: float | None = None,
  574. printer_model: str | None = None,
  575. ) -> list[dict]:
  576. """Async wrapper for listing files with timeout.
  577. Args:
  578. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  579. printer_model: Printer model for A1-specific workarounds
  580. """
  581. loop = asyncio.get_event_loop()
  582. def _list():
  583. client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
  584. if client.connect():
  585. try:
  586. return client.list_files(path)
  587. finally:
  588. client.disconnect()
  589. return []
  590. try:
  591. return await asyncio.wait_for(loop.run_in_executor(None, _list), timeout=timeout)
  592. except TimeoutError:
  593. logger.warning(f"FTP list_files timed out after {timeout}s for {path}")
  594. return []
  595. async def delete_file_async(
  596. ip_address: str,
  597. access_code: str,
  598. remote_path: str,
  599. socket_timeout: float | None = None,
  600. printer_model: str | None = None,
  601. ) -> bool:
  602. """Async wrapper for deleting a file.
  603. Args:
  604. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  605. printer_model: Printer model for A1-specific workarounds
  606. """
  607. loop = asyncio.get_event_loop()
  608. def _delete():
  609. client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
  610. if client.connect():
  611. try:
  612. return client.delete_file(remote_path)
  613. finally:
  614. client.disconnect()
  615. return False
  616. return await loop.run_in_executor(None, _delete)
  617. async def download_file_bytes_async(
  618. ip_address: str,
  619. access_code: str,
  620. remote_path: str,
  621. socket_timeout: float | None = None,
  622. printer_model: str | None = None,
  623. ) -> bytes | None:
  624. """Async wrapper for downloading file as bytes.
  625. Args:
  626. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  627. printer_model: Printer model for A1-specific workarounds
  628. """
  629. loop = asyncio.get_event_loop()
  630. def _download():
  631. client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
  632. if client.connect():
  633. try:
  634. return client.download_file(remote_path)
  635. finally:
  636. client.disconnect()
  637. return None
  638. return await loop.run_in_executor(None, _download)
  639. async def get_storage_info_async(
  640. ip_address: str,
  641. access_code: str,
  642. socket_timeout: float | None = None,
  643. printer_model: str | None = None,
  644. ) -> dict | None:
  645. """Async wrapper for getting storage info.
  646. Args:
  647. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  648. printer_model: Printer model for A1-specific workarounds
  649. """
  650. loop = asyncio.get_event_loop()
  651. def _get_storage():
  652. client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
  653. if client.connect():
  654. try:
  655. return client.get_storage_info()
  656. finally:
  657. client.disconnect()
  658. return None
  659. return await loop.run_in_executor(None, _get_storage)
  660. async def get_ftp_retry_settings() -> tuple[bool, int, float, float]:
  661. """Get FTP retry settings from database.
  662. Returns:
  663. Tuple of (retry_enabled, retry_count, retry_delay, timeout)
  664. """
  665. from backend.app.api.routes.settings import get_setting
  666. from backend.app.core.database import async_session
  667. async with async_session() as db:
  668. enabled = (await get_setting(db, "ftp_retry_enabled") or "true") == "true"
  669. count = int(await get_setting(db, "ftp_retry_count") or "3")
  670. delay = float(await get_setting(db, "ftp_retry_delay") or "2")
  671. timeout = float(await get_setting(db, "ftp_timeout") or "30")
  672. return enabled, count, delay, timeout
  673. async def with_ftp_retry(
  674. operation: Callable[..., Awaitable[T]],
  675. *args,
  676. max_retries: int = 3,
  677. retry_delay: float = 2.0,
  678. operation_name: str = "FTP operation",
  679. **kwargs,
  680. ) -> T | None:
  681. """Execute FTP operation with retry logic.
  682. Args:
  683. operation: Async function to execute
  684. *args: Positional arguments for the operation
  685. max_retries: Number of retry attempts (default: 3)
  686. retry_delay: Seconds to wait between retries (default: 2.0)
  687. operation_name: Name for logging purposes
  688. **kwargs: Keyword arguments for the operation
  689. Returns:
  690. Result of the operation, or None if all attempts fail
  691. """
  692. last_error = None
  693. for attempt in range(max_retries + 1):
  694. try:
  695. result = await operation(*args, **kwargs)
  696. # Check for "falsy" success indicators
  697. if result not in (False, None, []):
  698. if attempt > 0:
  699. logger.info(f"{operation_name} succeeded on attempt {attempt + 1}/{max_retries + 1}")
  700. return result
  701. # Operation returned failure indicator
  702. if attempt > 0:
  703. logger.info(f"{operation_name} attempt {attempt + 1}/{max_retries + 1} returned failure")
  704. except Exception as e:
  705. last_error = e
  706. logger.warning(f"{operation_name} attempt {attempt + 1}/{max_retries + 1} failed: {e}")
  707. # Don't wait after the last attempt
  708. if attempt < max_retries:
  709. logger.info(f"{operation_name} will retry in {retry_delay}s...")
  710. await asyncio.sleep(retry_delay)
  711. logger.error(f"{operation_name} failed after {max_retries + 1} attempts")
  712. if last_error:
  713. logger.debug(f"Last error: {last_error}")
  714. return None