bambu_ftp.py 33 KB

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