bambu_ftp.py 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145
  1. import asyncio
  2. import ftplib # nosec B402
  3. import logging
  4. import os
  5. import socket
  6. import ssl
  7. import threading
  8. import time
  9. from collections.abc import Awaitable, Callable
  10. from ftplib import FTP, FTP_TLS # nosec B402
  11. from io import BytesIO
  12. from pathlib import Path
  13. from typing import TypeVar
  14. logger = logging.getLogger(__name__)
  15. T = TypeVar("T")
  16. class FileNotOnPrinterError(Exception):
  17. """Raised when a remote FTP path returns 550 (file not found).
  18. 550 means the file does not exist at that path — retrying the same path
  19. will never succeed. Callers use this sentinel with with_ftp_retry's
  20. non_retry_exceptions to immediately move on to the next candidate path
  21. instead of burning the full retry budget (up to 11 × 30s per path) on
  22. a lookup that cannot recover.
  23. """
  24. class ImplicitFTP_TLS(FTP_TLS):
  25. """FTP_TLS subclass for implicit FTPS (port 990) with model-specific SSL handling.
  26. X1C/P1S printers (vsFTPd) require SSL with session reuse on the data channel.
  27. A1/A1 Mini printers have issues with SSL on the data channel entirely and
  28. timeout waiting for transfer completion. Set skip_session_reuse=True for A1
  29. printers to skip SSL on the data channel (control channel remains encrypted).
  30. """
  31. def __init__(self, *args, skip_session_reuse: bool = False, **kwargs):
  32. super().__init__(*args, **kwargs)
  33. self._sock = None
  34. self.skip_session_reuse = skip_session_reuse
  35. self.ssl_context = ssl.create_default_context()
  36. self.ssl_context.check_hostname = False
  37. self.ssl_context.verify_mode = ssl.CERT_NONE
  38. def connect(self, host="", port=990, timeout=-999, source_address=None):
  39. """Connect to host, wrapping socket in TLS immediately (implicit FTPS)."""
  40. if host:
  41. self.host = host
  42. if port > 0:
  43. self.port = port
  44. if timeout != -999:
  45. self.timeout = timeout
  46. if source_address:
  47. self.source_address = source_address
  48. # Create and wrap socket immediately (implicit TLS)
  49. self.sock = socket.create_connection((self.host, self.port), self.timeout, source_address=self.source_address)
  50. self.sock = self.ssl_context.wrap_socket(self.sock, server_hostname=self.host)
  51. self.af = self.sock.family
  52. self.file = self.sock.makefile("r", encoding=self.encoding)
  53. self.welcome = self.getresp()
  54. return self.welcome
  55. def ntransfercmd(self, cmd, rest=None):
  56. """Override to wrap data connection in SSL for X1C/P1S only.
  57. X1C/P1S printers (vsFTPd) require SSL session reuse on the data channel.
  58. A1/A1 Mini printers have issues with SSL on the data channel entirely -
  59. they timeout waiting for the transfer completion response. For A1, we
  60. skip SSL wrapping on the data channel (control channel remains encrypted).
  61. """
  62. conn, size = FTP.ntransfercmd(self, cmd, rest)
  63. if self._prot_p and not self.skip_session_reuse:
  64. # X1C/P1S: Wrap data channel with SSL session reuse (required by vsFTPd)
  65. conn = self.ssl_context.wrap_socket(
  66. conn,
  67. server_hostname=self.host,
  68. session=self.sock.session,
  69. )
  70. # A1/A1 Mini (skip_session_reuse=True): Don't wrap data channel in SSL
  71. # The control channel remains encrypted via implicit FTPS
  72. return conn, size
  73. class BambuFTPClient:
  74. """FTP client for retrieving files from Bambu Lab printers."""
  75. FTP_PORT = 990
  76. # Default timeout in seconds (increased for A1 printers)
  77. DEFAULT_TIMEOUT = 30
  78. # Models that may need SSL mode fallback (try prot_p first, fall back to prot_c)
  79. # These models have varying FTP SSL behavior depending on firmware version
  80. A1_MODELS = ("A1", "A1 Mini")
  81. # Chunk size for manual upload transfer (64KB)
  82. # Smaller chunks provide smoother progress reporting — at typical printer FTP
  83. # speeds (~50-100KB/s) this gives a progress update roughly every second.
  84. CHUNK_SIZE = 64 * 1024
  85. # Cache for working FTP modes per printer IP
  86. # Maps IP -> "prot_p" or "prot_c"
  87. _mode_cache: dict[str, str] = {}
  88. def __init__(
  89. self,
  90. ip_address: str,
  91. access_code: str,
  92. timeout: float | None = None,
  93. printer_model: str | None = None,
  94. force_prot_c: bool = False,
  95. ):
  96. self.ip_address = ip_address
  97. self.access_code = access_code
  98. self.timeout = timeout if timeout is not None else self.DEFAULT_TIMEOUT
  99. self.printer_model = printer_model
  100. self.force_prot_c = force_prot_c
  101. self._ftp: ImplicitFTP_TLS | None = None
  102. def _is_a1_model(self) -> bool:
  103. """Check if this is an A1 series printer."""
  104. if not self.printer_model:
  105. return False
  106. return self.printer_model in self.A1_MODELS
  107. def _get_cached_mode(self) -> str | None:
  108. """Get cached FTP mode for this printer."""
  109. return self._mode_cache.get(self.ip_address)
  110. @classmethod
  111. def cache_mode(cls, ip_address: str, mode: str):
  112. """Cache the working FTP mode for a printer."""
  113. cls._mode_cache[ip_address] = mode
  114. logger.info("FTP mode cached for %s: %s", ip_address, mode)
  115. def _should_use_prot_c(self) -> bool:
  116. """Determine if we should use prot_c (clear) mode."""
  117. # If explicitly forced, use prot_c
  118. if self.force_prot_c:
  119. return True
  120. # Check cache first
  121. cached = self._get_cached_mode()
  122. if cached:
  123. return cached == "prot_c"
  124. # Default: try prot_p first (will fall back if needed)
  125. return False
  126. def connect(self) -> bool:
  127. """Connect to the printer FTP server (implicit FTPS on port 990)."""
  128. try:
  129. use_prot_c = self._should_use_prot_c()
  130. logger.debug(
  131. f"FTP connecting to {self.ip_address}:{self.FTP_PORT} "
  132. f"(timeout={self.timeout}s, model={self.printer_model}, prot_c={use_prot_c})"
  133. )
  134. self._ftp = ImplicitFTP_TLS(skip_session_reuse=use_prot_c)
  135. self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=self.timeout)
  136. logger.debug("FTP connected, logging in as bblp")
  137. self._ftp.login("bblp", self.access_code)
  138. if use_prot_c:
  139. # Use clear (unencrypted) data channel
  140. logger.debug("FTP logged in, setting prot_c (clear) and passive mode")
  141. self._ftp.prot_c()
  142. else:
  143. # Use protected (encrypted) data channel with session reuse
  144. logger.debug("FTP logged in, setting prot_p (protected) and passive mode")
  145. self._ftp.prot_p()
  146. self._ftp.set_pasv(True)
  147. # Log welcome message for debugging
  148. if hasattr(self._ftp, "welcome") and self._ftp.welcome:
  149. logger.debug("FTP server welcome: %s", self._ftp.welcome)
  150. logger.info(
  151. f"FTP connected successfully to {self.ip_address} (model={self.printer_model}, prot_c={use_prot_c})"
  152. )
  153. return True
  154. except ftplib.error_perm as e:
  155. logger.warning("FTP connection permission error to %s: %s", self.ip_address, e)
  156. self._ftp = None
  157. return False
  158. except TimeoutError as e:
  159. logger.warning("FTP connection timed out to %s: %s", self.ip_address, e)
  160. self._ftp = None
  161. return False
  162. except ssl.SSLError as e:
  163. logger.warning("FTP SSL error connecting to %s: %s", self.ip_address, e)
  164. self._ftp = None
  165. return False
  166. except (OSError, ftplib.Error) as e:
  167. logger.warning("FTP connection failed to %s: %s (type: %s)", self.ip_address, e, type(e).__name__)
  168. self._ftp = None
  169. return False
  170. def disconnect(self):
  171. """Disconnect from the FTP server."""
  172. if self._ftp:
  173. try:
  174. self._ftp.quit()
  175. except (OSError, ftplib.Error, EOFError):
  176. pass # Best-effort FTP cleanup; connection may already be closed
  177. self._ftp = None
  178. def list_files(self, path: str = "/") -> list[dict]:
  179. """List files in a directory."""
  180. if not self._ftp:
  181. return []
  182. files = []
  183. try:
  184. self._ftp.cwd(path)
  185. items = []
  186. self._ftp.retrlines("LIST", items.append)
  187. for item in items:
  188. parts = item.split()
  189. if len(parts) >= 9:
  190. name = " ".join(parts[8:])
  191. is_dir = item.startswith("d")
  192. size = int(parts[4]) if not is_dir else 0
  193. # Parse modification time from FTP listing
  194. # Format: "Nov 30 10:15" or "Nov 30 2024"
  195. mtime = None
  196. try:
  197. from datetime import datetime
  198. month = parts[5]
  199. day = parts[6]
  200. time_or_year = parts[7]
  201. # Determine if it's time (HH:MM) or year
  202. if ":" in time_or_year:
  203. # Recent file: "Nov 30 10:15" - assume current year
  204. year = datetime.now().year
  205. time_str = f"{month} {day} {year} {time_or_year}"
  206. mtime = datetime.strptime(time_str, "%b %d %Y %H:%M")
  207. # If parsed date is in the future, use last year
  208. if mtime > datetime.now():
  209. mtime = mtime.replace(year=year - 1)
  210. else:
  211. # Older file: "Nov 30 2024" - no time, just date
  212. time_str = f"{month} {day} {time_or_year}"
  213. mtime = datetime.strptime(time_str, "%b %d %Y")
  214. except (ValueError, IndexError):
  215. pass # Non-critical: mtime parsing is best-effort; file entry works without it
  216. file_entry = {
  217. "name": name,
  218. "is_directory": is_dir,
  219. "size": size,
  220. "path": f"{path.rstrip('/')}/{name}",
  221. }
  222. if mtime:
  223. file_entry["mtime"] = mtime
  224. files.append(file_entry)
  225. logger.debug("Listed %s files in %s", len(files), path)
  226. except (OSError, ftplib.Error) as e:
  227. logger.info("FTP list_files failed for %s: %s", path, e)
  228. return files
  229. def download_file(self, remote_path: str) -> bytes | None:
  230. """Download a file from the printer."""
  231. if not self._ftp:
  232. return None
  233. try:
  234. buffer = BytesIO()
  235. self._ftp.retrbinary(f"RETR {remote_path}", buffer.write)
  236. return buffer.getvalue()
  237. except (OSError, ftplib.Error):
  238. return None
  239. def download_to_file(self, remote_path: str, local_path: Path) -> bool:
  240. """Download a file from the printer to local filesystem."""
  241. if not self._ftp:
  242. logger.warning("download_to_file called but FTP not connected")
  243. return False
  244. try:
  245. local_path.parent.mkdir(parents=True, exist_ok=True)
  246. with open(local_path, "wb") as f:
  247. self._ftp.retrbinary(f"RETR {remote_path}", f.write)
  248. f.flush()
  249. os.fsync(f.fileno())
  250. file_size = local_path.stat().st_size if local_path.exists() else 0
  251. if file_size == 0:
  252. logger.warning("FTP download returned 0 bytes for %s", remote_path)
  253. if local_path.exists():
  254. local_path.unlink()
  255. return False
  256. logger.info("Successfully downloaded %s to %s (%s bytes)", remote_path, local_path, file_size)
  257. return True
  258. except (OSError, ftplib.Error) as e:
  259. # Clean up partial file if it exists
  260. if local_path.exists():
  261. try:
  262. local_path.unlink()
  263. except OSError:
  264. pass # Best-effort partial file cleanup; not critical if removal fails
  265. # 550 means the file is not at this path. Surface as a sentinel so
  266. # with_ftp_retry can abandon this path immediately and the caller
  267. # can advance to the next candidate instead of retrying 11× at
  268. # 30s intervals (the pattern that cost #972's reporter ~48min).
  269. if isinstance(e, ftplib.error_perm) and str(e).startswith("550"):
  270. logger.info("FTP download failed for %s: %s (not on printer)", remote_path, e)
  271. raise FileNotOnPrinterError(f"{remote_path}: {e}") from e
  272. # Log at INFO level so we can see failures in normal logs
  273. logger.info("FTP download failed for %s: %s", remote_path, e)
  274. return False
  275. def diagnose_storage(self) -> dict:
  276. """Run storage diagnostics and return results. For debugging upload issues."""
  277. results = {
  278. "connected": self._ftp is not None,
  279. "can_list_root": False,
  280. "root_files": [],
  281. "can_list_cache": False,
  282. "storage_info": None,
  283. "pwd": None,
  284. "errors": [],
  285. }
  286. if not self._ftp:
  287. results["errors"].append("FTP not connected")
  288. return results
  289. # Try to get current directory
  290. try:
  291. results["pwd"] = self._ftp.pwd()
  292. logger.debug("FTP current directory: %s", results["pwd"])
  293. except (OSError, ftplib.Error) as e:
  294. results["errors"].append(f"PWD failed: {e}")
  295. logger.debug("FTP PWD failed: %s", e)
  296. # Try to list root directory
  297. try:
  298. self._ftp.cwd("/")
  299. items = []
  300. self._ftp.retrlines("LIST", items.append)
  301. results["can_list_root"] = True
  302. results["root_files"] = items[:10] # First 10 entries
  303. logger.debug("FTP root listing (%s items): %s", len(items), items[:5])
  304. except (OSError, ftplib.Error) as e:
  305. results["errors"].append(f"LIST / failed: {e}")
  306. logger.debug("FTP LIST / failed: %s", e)
  307. # Try to list /cache (should exist on all printers)
  308. try:
  309. self._ftp.cwd("/cache")
  310. items = []
  311. self._ftp.retrlines("LIST", items.append)
  312. results["can_list_cache"] = True
  313. logger.debug("FTP /cache listing: %s items", len(items))
  314. except (OSError, ftplib.Error) as e:
  315. results["errors"].append(f"LIST /cache failed: {e}")
  316. logger.debug("FTP LIST /cache failed: %s", e)
  317. # Try to get storage info
  318. try:
  319. results["storage_info"] = self.get_storage_info()
  320. logger.debug("FTP storage info: %s", results["storage_info"])
  321. except (OSError, ftplib.Error) as e:
  322. results["errors"].append(f"Storage info failed: {e}")
  323. return results
  324. def upload_file(
  325. self,
  326. local_path: Path,
  327. remote_path: str,
  328. progress_callback: Callable[[int, int], None] | None = None,
  329. ) -> bool:
  330. """Upload a file to the printer with optional progress callback."""
  331. if not self._ftp:
  332. logger.warning("upload_file: FTP not connected")
  333. return False
  334. try:
  335. file_size = local_path.stat().st_size if local_path.exists() else 0
  336. logger.info("FTP uploading %s (%s bytes) to %s", local_path, file_size, remote_path)
  337. uploaded = 0
  338. callback_exception: Exception | None = None
  339. # Use manual transfer instead of storbinary() for A1 compatibility
  340. # A1 printers have issues with storbinary's voidresp() hanging after transfer
  341. with open(local_path, "rb") as f:
  342. logger.debug("FTP STOR command starting for %s", remote_path)
  343. t0 = time.monotonic()
  344. conn = self._ftp.transfercmd(f"STOR {remote_path}")
  345. logger.info(
  346. "FTP data channel ready in %.1fs (PASV + TLS handshake)",
  347. time.monotonic() - t0,
  348. )
  349. # Set explicit socket options for reliable transfer
  350. conn.setblocking(True)
  351. conn.settimeout(self.timeout)
  352. try:
  353. while True:
  354. chunk = f.read(self.CHUNK_SIZE)
  355. if not chunk:
  356. logger.debug("FTP upload: final chunk reached")
  357. break
  358. conn.sendall(chunk)
  359. uploaded += len(chunk)
  360. logger.debug("FTP upload progress: %s/%s bytes", uploaded, file_size)
  361. if progress_callback:
  362. try:
  363. progress_callback(uploaded, file_size)
  364. except Exception as e:
  365. callback_exception = e
  366. logger.info(
  367. "FTP upload callback requested stop for %s at %s/%s bytes: %s",
  368. remote_path,
  369. uploaded,
  370. file_size,
  371. e,
  372. )
  373. break
  374. except OSError as e:
  375. logger.error("FTP connection lost during upload: %s", e)
  376. raise
  377. finally:
  378. try:
  379. conn.close()
  380. except OSError:
  381. pass
  382. # Wait for the server's 226 "Transfer complete" response to confirm
  383. # the file has been flushed to the SD card. Without this, the printer
  384. # may try to read an incomplete file when the print command is sent,
  385. # causing 0500-C010 "MicroSD Card read/write exception" errors.
  386. # See: https://bugs.python.org/issue25458 (ftplib response desync)
  387. try:
  388. old_timeout = self._ftp.sock.gettimeout()
  389. # Use a generous timeout — H2D printers can take 30+ seconds
  390. # to send the 226 after the data channel closes.
  391. self._ftp.sock.settimeout(max(self.timeout, 60))
  392. try:
  393. resp = self._ftp.voidresp()
  394. logger.info("FTP STOR confirmed for %s: %s", remote_path, resp.strip())
  395. finally:
  396. self._ftp.sock.settimeout(old_timeout)
  397. except ftplib.Error as e:
  398. # The printer's FTP server explicitly told us the transfer
  399. # failed (e.g. 426 "Failure reading network stream" seen on
  400. # some P2S firmware revisions). The file on the SD card is
  401. # truncated — never report success or send a print command
  402. # for it. Re-raise so the outer handler returns False.
  403. logger.error(
  404. "FTP STOR rejected by printer for %s: %s (%s)",
  405. remote_path,
  406. e,
  407. type(e).__name__,
  408. )
  409. raise
  410. except Exception as e:
  411. # Timeout or socket-level error reading 226 — the data was sent
  412. # on our side and the printer may still have written the file.
  413. # H2D can take 30+ seconds to send 226 after the data channel
  414. # closes, so we proceed with a warning rather than failing here.
  415. logger.warning(
  416. "FTP STOR confirmation not received for %s (proceeding): %s (%s)",
  417. remote_path,
  418. e,
  419. type(e).__name__,
  420. )
  421. if callback_exception is not None:
  422. cleanup_ok = False
  423. try:
  424. cleanup_ok = self.delete_file(remote_path)
  425. except Exception as cleanup_error:
  426. logger.warning("FTP cancel cleanup failed for %s: %s", remote_path, cleanup_error)
  427. if cleanup_ok:
  428. logger.info("FTP cancel cleanup succeeded for %s", remote_path)
  429. raise callback_exception
  430. raise RuntimeError(
  431. f"Upload cancelled but failed to remove partial file {remote_path} from printer"
  432. ) from callback_exception
  433. elapsed = time.monotonic() - t0
  434. speed_kbs = (file_size / 1024) / elapsed if elapsed > 0 else 0
  435. logger.info(
  436. "FTP upload complete: %s (%s bytes in %.1fs, %.0f KB/s)",
  437. remote_path,
  438. file_size,
  439. elapsed,
  440. speed_kbs,
  441. )
  442. return True
  443. except ftplib.error_perm as e:
  444. # Permanent FTP error (4xx/5xx response)
  445. error_code = str(e)[:3] if str(e) else "unknown"
  446. logger.error("FTP upload failed for %s: %s (error code: %s)", remote_path, e, error_code)
  447. if error_code == "553":
  448. logger.error(
  449. "FTP 553 error - Could not create file. Possible causes: "
  450. "1) No SD card inserted, 2) SD card full, 3) SD card not formatted correctly (needs FAT32/exFAT), "
  451. "4) Printer busy/not ready, 5) File path issue"
  452. )
  453. elif error_code == "550":
  454. logger.error("FTP 550 error - File/directory not found or permission denied")
  455. elif error_code == "552":
  456. logger.error("FTP 552 error - Storage quota exceeded (SD card full?)")
  457. return False
  458. except (OSError, ftplib.Error) as e:
  459. logger.error("FTP upload failed for %s: %s (type: %s)", remote_path, e, type(e).__name__)
  460. return False
  461. def upload_bytes(self, data: bytes, remote_path: str) -> bool:
  462. """Upload bytes to the printer."""
  463. if not self._ftp:
  464. return False
  465. try:
  466. # Use manual transfer instead of storbinary() for A1 compatibility
  467. conn = self._ftp.transfercmd(f"STOR {remote_path}")
  468. conn.setblocking(True)
  469. conn.settimeout(self.timeout)
  470. try:
  471. # Send data in chunks
  472. offset = 0
  473. while offset < len(data):
  474. chunk = data[offset : offset + self.CHUNK_SIZE]
  475. conn.sendall(chunk)
  476. offset += len(chunk)
  477. except OSError as e:
  478. logger.error("FTP connection lost during upload_bytes: %s", e)
  479. raise
  480. finally:
  481. try:
  482. conn.close()
  483. except OSError:
  484. pass
  485. # Wait for 226 confirmation (see upload_file for rationale).
  486. # ftplib.Error subclasses (e.g. 426 error_temp) mean the server
  487. # rejected the transfer and the file is partial — fail. Other
  488. # exceptions (timeout, socket-level) are tolerated as in upload_file.
  489. try:
  490. old_timeout = self._ftp.sock.gettimeout()
  491. self._ftp.sock.settimeout(max(self.timeout, 60))
  492. try:
  493. self._ftp.voidresp()
  494. finally:
  495. self._ftp.sock.settimeout(old_timeout)
  496. except ftplib.Error as e:
  497. logger.error(
  498. "FTP STOR rejected by printer for %s: %s (%s)",
  499. remote_path,
  500. e,
  501. type(e).__name__,
  502. )
  503. return False
  504. except Exception:
  505. pass # Timeout / socket-level — proceed, data was sent.
  506. return True
  507. except (OSError, ftplib.Error):
  508. return False
  509. def delete_file(self, remote_path: str) -> bool:
  510. """Delete a file from the printer."""
  511. if not self._ftp:
  512. return False
  513. try:
  514. self._ftp.delete(remote_path)
  515. return True
  516. except (OSError, ftplib.Error) as e:
  517. logger.warning("Failed to delete %s: %s", remote_path, e)
  518. return False
  519. def get_file_size(self, remote_path: str) -> int | None:
  520. """Get the size of a file."""
  521. if not self._ftp:
  522. return None
  523. try:
  524. return self._ftp.size(remote_path)
  525. except (OSError, ftplib.Error):
  526. return None
  527. def get_storage_info(self) -> dict | None:
  528. """Get storage information from the printer."""
  529. if not self._ftp:
  530. return None
  531. result = {}
  532. # Try AVBL command (available space) - some FTP servers support this
  533. try:
  534. response = self._ftp.sendcmd("AVBL")
  535. logger.debug("AVBL response: %s", response)
  536. # Response format: "213 <bytes available>"
  537. if response.startswith("213"):
  538. parts = response.split()
  539. if len(parts) >= 2:
  540. result["free_bytes"] = int(parts[1])
  541. except (OSError, ftplib.Error) as e:
  542. logger.debug("AVBL command not supported: %s", e)
  543. # Try STAT command as fallback
  544. try:
  545. response = self._ftp.sendcmd("STAT")
  546. logger.debug("STAT response: %s", response)
  547. except (OSError, ftplib.Error):
  548. pass # Both AVBL and STAT unsupported; storage info will rely on directory scan
  549. # Calculate used space by listing root directories
  550. try:
  551. total_used = 0
  552. dirs_to_scan = ["/cache", "/timelapse", "/model", "/data", "/data/Metadata", "/"]
  553. for dir_path in dirs_to_scan:
  554. try:
  555. self._ftp.cwd(dir_path)
  556. items = []
  557. self._ftp.retrlines("LIST", items.append)
  558. for item in items:
  559. parts = item.split()
  560. if len(parts) >= 5 and not item.startswith("d"):
  561. try:
  562. total_used += int(parts[4])
  563. except ValueError:
  564. pass # Skip entries with non-numeric size fields
  565. except (OSError, ftplib.Error):
  566. pass # Directory may not exist on this printer model; skip it
  567. result["used_bytes"] = total_used
  568. except (OSError, ftplib.Error):
  569. pass # Storage scan failed; return whatever info was collected above
  570. return result if result else None
  571. # Shared 3MF download cache (#972).
  572. #
  573. # Both the cover thumbnail endpoint (api/routes/printers.py) and the archive
  574. # metadata flow (main.py) fetch the same 3MF file over FTP during a print.
  575. # On slow / contended links (A1 Wi-Fi, large files) the duplicate transfers
  576. # compete for the printer's single FTP socket and trigger 425 "can't open
  577. # data channel" errors, feeding back into cause-2's retry storm.
  578. #
  579. # This cache stores the local path of a successfully-downloaded 3MF keyed
  580. # by (printer_id, normalized_name). Whichever flow downloads first populates
  581. # the cache; the other flow reuses the file read-only. Evicted on print
  582. # completion so a later print with the same name re-downloads fresh bytes.
  583. _threemf_path_cache: dict[tuple[int, str], Path] = {}
  584. def normalize_3mf_name(name: str) -> str:
  585. """Collapse various 3MF filename variants to a cache key.
  586. Bambu tooling produces names as bare subtask ("Part"), with .3mf, with
  587. .gcode.3mf, or (Studio-normalized) with spaces → underscores. All of
  588. these refer to the same print job on the same printer, so they must
  589. hash to the same cache key.
  590. """
  591. # Lowercase first so .3MF / .GCODE.3MF variants strip cleanly — a
  592. # real-world case since Windows-side tooling sometimes uppercases
  593. # extensions.
  594. cleaned = name.strip().lower().replace(".gcode.3mf", "").replace(".gcode", "").replace(".3mf", "")
  595. return cleaned.replace(" ", "_")
  596. def cache_3mf_download(printer_id: int, name: str, local_path: Path) -> None:
  597. """Record a successfully-downloaded 3MF so a sibling flow can reuse it."""
  598. _threemf_path_cache[(printer_id, normalize_3mf_name(name))] = local_path
  599. def get_cached_3mf(printer_id: int, name: str) -> Path | None:
  600. """Return a cached 3MF path for this printer/name if the file still exists."""
  601. key = (printer_id, normalize_3mf_name(name))
  602. cached = _threemf_path_cache.get(key)
  603. if cached and cached.exists() and cached.stat().st_size > 0:
  604. return cached
  605. # Evict dead entry — the file was cleaned up (temp dir clean, manual
  606. # deletion, restart) so the cache value is no longer usable.
  607. if cached:
  608. _threemf_path_cache.pop(key, None)
  609. return None
  610. def clear_3mf_cache(printer_id: int | None = None, delete_files: bool = True) -> None:
  611. """Drop cache entries for one printer (or all with None).
  612. When ``delete_files`` is True (default) the on-disk 3MF is removed as well
  613. — called from on_print_complete so temp files don't accumulate across
  614. prints. Tests that want to inspect the cache contents disable this.
  615. Only paths inside ``archive_dir/temp`` are unlinked. The dispatch sites
  616. added in #1166 also cache the live archive copy and library file bytes
  617. so /cover can skip FTP — those are *user data*, never the cache's to
  618. delete. Pre-fix this branch silently removed archive 3mfs on every print
  619. completion (#1212 + private reports of "file disappeared overnight").
  620. """
  621. from backend.app.core.config import settings as _config_settings
  622. temp_root = _config_settings.archive_dir / "temp"
  623. def _is_temp_path(path: Path) -> bool:
  624. try:
  625. return path.is_relative_to(temp_root)
  626. except (OSError, ValueError):
  627. return False
  628. def _maybe_unlink(path: Path) -> None:
  629. if not delete_files or not path.exists():
  630. return
  631. if not _is_temp_path(path):
  632. return
  633. try:
  634. path.unlink()
  635. except OSError as exc:
  636. logger.debug("3MF cache cleanup skipped %s: %s", path, exc)
  637. if printer_id is None:
  638. for path in list(_threemf_path_cache.values()):
  639. _maybe_unlink(path)
  640. _threemf_path_cache.clear()
  641. return
  642. for key in [k for k in _threemf_path_cache if k[0] == printer_id]:
  643. _maybe_unlink(_threemf_path_cache[key])
  644. _threemf_path_cache.pop(key, None)
  645. async def download_file_async(
  646. ip_address: str,
  647. access_code: str,
  648. remote_path: str,
  649. local_path: Path,
  650. timeout: float = 60.0,
  651. socket_timeout: float | None = None,
  652. printer_model: str | None = None,
  653. ) -> bool:
  654. """Async wrapper for downloading a file with timeout.
  655. For A1/A1 Mini printers, automatically tries prot_p first, then falls back
  656. to prot_c if the download fails. The working mode is cached for future operations.
  657. Args:
  658. ip_address: Printer IP address
  659. access_code: Printer access code
  660. remote_path: Remote file path on printer
  661. local_path: Local path to save file
  662. timeout: Overall operation timeout (asyncio)
  663. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  664. printer_model: Printer model for A1-specific workarounds
  665. """
  666. loop = asyncio.get_event_loop()
  667. is_a1 = printer_model in BambuFTPClient.A1_MODELS if printer_model else False
  668. # Per-attempt completion state: asyncio.wait_for cannot cancel
  669. # run_in_executor threads, so on timeout the executor may still complete
  670. # the download after we stop waiting. The thread flips `success` to True
  671. # ONLY after the file is fully written — a post-timeout check lets us
  672. # salvage the download without mistaking an in-progress partial write
  673. # for a completed one. Each attempt gets its own dict and event so a
  674. # zombie from an earlier attempt can't flip the flag for a later one.
  675. # The event is set in `_download`'s finally block so the post-timeout
  676. # path can wait for genuine thread completion instead of a fixed sleep.
  677. def _download(force_prot_c: bool, completion: dict, done: threading.Event) -> bool:
  678. mode_str = "prot_c" if force_prot_c else "prot_p"
  679. try:
  680. client = BambuFTPClient(
  681. ip_address,
  682. access_code,
  683. timeout=socket_timeout,
  684. printer_model=printer_model,
  685. force_prot_c=force_prot_c,
  686. )
  687. if client.connect():
  688. try:
  689. result = client.download_to_file(remote_path, local_path)
  690. if result:
  691. BambuFTPClient.cache_mode(ip_address, mode_str)
  692. completion["success"] = True
  693. return result
  694. finally:
  695. client.disconnect()
  696. return False
  697. finally:
  698. done.set()
  699. async def _run(force_prot_c: bool) -> bool:
  700. completion = {"success": False}
  701. done = threading.Event()
  702. try:
  703. return await asyncio.wait_for(
  704. loop.run_in_executor(None, _download, force_prot_c, completion, done), timeout=timeout
  705. )
  706. except TimeoutError:
  707. # Slow WiFi links commonly overshoot ftp_timeout by 10–30 s without
  708. # actually being stuck, so starting attempt 2 now would just contend
  709. # with the still-progressing RETR on attempt 1 and produce the
  710. # zombie-write race reported in #1014 (file landed on disk minutes
  711. # after the retry loop had already given up). Wait for the worker
  712. # thread to genuinely finish — capped at 30 s so a truly stuck
  713. # connection can't stall a whole attempt indefinitely, with a 0.5 s
  714. # floor so artificially small test timeouts still give zombies a
  715. # realistic window to finish.
  716. grace = max(min(timeout, 30.0), 0.5)
  717. await loop.run_in_executor(None, done.wait, grace)
  718. if completion["success"] and local_path.exists() and local_path.stat().st_size > 0:
  719. logger.info(
  720. "FTP download wait_for timed out after %ss for %s, but thread completed within %ss grace (%s bytes) — salvaging",
  721. timeout,
  722. remote_path,
  723. grace,
  724. local_path.stat().st_size,
  725. )
  726. return True
  727. logger.warning(
  728. "FTP download timed out after %ss (plus %ss grace) for %s",
  729. timeout,
  730. grace,
  731. remote_path,
  732. )
  733. return False
  734. # Check if we have a cached mode for this printer
  735. cached_mode = BambuFTPClient._mode_cache.get(ip_address)
  736. if cached_mode:
  737. force_prot_c = cached_mode == "prot_c"
  738. return await _run(force_prot_c)
  739. # No cached mode - try prot_p first
  740. if await _run(False):
  741. return True
  742. # Download failed - for A1 models, try prot_c fallback
  743. if is_a1:
  744. logger.info("FTP download failed with prot_p for A1 model, trying prot_c fallback...")
  745. return await _run(True)
  746. return False
  747. async def download_file_try_paths_async(
  748. ip_address: str,
  749. access_code: str,
  750. remote_paths: list[str],
  751. local_path: Path,
  752. socket_timeout: float | None = None,
  753. printer_model: str | None = None,
  754. ) -> bool:
  755. """Try downloading a file from multiple paths using a single connection.
  756. Args:
  757. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  758. printer_model: Printer model for A1-specific workarounds
  759. """
  760. loop = asyncio.get_event_loop()
  761. def _download():
  762. client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
  763. if not client.connect():
  764. return False
  765. try:
  766. # FileNotOnPrinterError signals "try the next path", not "give up" —
  767. # this function's whole purpose is to walk a list of candidates
  768. # over one connection. Only a real transport error should bubble.
  769. for remote_path in remote_paths:
  770. try:
  771. if client.download_to_file(remote_path, local_path):
  772. return True
  773. except FileNotOnPrinterError:
  774. continue
  775. return False
  776. finally:
  777. client.disconnect()
  778. return await loop.run_in_executor(None, _download)
  779. async def upload_file_async(
  780. ip_address: str,
  781. access_code: str,
  782. local_path: Path,
  783. remote_path: str,
  784. timeout: float = 600.0,
  785. progress_callback: Callable[[int, int], None] | None = None,
  786. socket_timeout: float | None = None,
  787. printer_model: str | None = None,
  788. ) -> bool:
  789. """Async wrapper for uploading a file with timeout and progress callback.
  790. For A1/A1 Mini printers, automatically tries prot_p first, then falls back
  791. to prot_c if the upload fails. The working mode is cached for future uploads.
  792. Args:
  793. ip_address: Printer IP address
  794. access_code: Printer access code
  795. local_path: Local file path to upload
  796. remote_path: Remote path on printer
  797. timeout: Overall operation timeout (asyncio)
  798. progress_callback: Optional callback for progress updates
  799. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  800. printer_model: Printer model for A1-specific workarounds
  801. """
  802. loop = asyncio.get_event_loop()
  803. is_a1 = printer_model in BambuFTPClient.A1_MODELS if printer_model else False
  804. def _upload(force_prot_c: bool = False) -> bool:
  805. mode_str = "prot_c" if force_prot_c else "prot_p"
  806. logger.info(
  807. f"FTP connecting to {ip_address} for upload (model={printer_model}, "
  808. f"mode={mode_str}, socket_timeout={socket_timeout}s)..."
  809. )
  810. client = BambuFTPClient(
  811. ip_address, access_code, timeout=socket_timeout, printer_model=printer_model, force_prot_c=force_prot_c
  812. )
  813. if client.connect():
  814. logger.info("FTP connected to %s", ip_address)
  815. try:
  816. result = client.upload_file(local_path, remote_path, progress_callback)
  817. if result:
  818. # Cache the working mode
  819. BambuFTPClient.cache_mode(ip_address, mode_str)
  820. return result
  821. finally:
  822. client.disconnect()
  823. logger.warning("FTP connection failed to %s", ip_address)
  824. return False
  825. try:
  826. # Check if we have a cached mode for this printer
  827. cached_mode = BambuFTPClient._mode_cache.get(ip_address)
  828. if cached_mode:
  829. # Use cached mode
  830. force_prot_c = cached_mode == "prot_c"
  831. return await asyncio.wait_for(loop.run_in_executor(None, lambda: _upload(force_prot_c)), timeout=timeout)
  832. # No cached mode - try prot_p first
  833. result = await asyncio.wait_for(loop.run_in_executor(None, lambda: _upload(False)), timeout=timeout)
  834. if result:
  835. return True
  836. # Upload failed - for A1 models, try prot_c fallback
  837. if is_a1:
  838. logger.info("FTP upload failed with prot_p for A1 model, trying prot_c fallback...")
  839. result = await asyncio.wait_for(loop.run_in_executor(None, lambda: _upload(True)), timeout=timeout)
  840. return result
  841. return False
  842. except TimeoutError:
  843. logger.warning("FTP upload timed out after %ss for %s", timeout, remote_path)
  844. return False
  845. async def list_files_async(
  846. ip_address: str,
  847. access_code: str,
  848. path: str = "/",
  849. timeout: float = 30.0,
  850. socket_timeout: float | None = None,
  851. printer_model: str | None = None,
  852. ) -> list[dict]:
  853. """Async wrapper for listing files with timeout.
  854. Args:
  855. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  856. printer_model: Printer model for A1-specific workarounds
  857. """
  858. loop = asyncio.get_event_loop()
  859. def _list():
  860. client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
  861. if client.connect():
  862. try:
  863. return client.list_files(path)
  864. finally:
  865. client.disconnect()
  866. return []
  867. try:
  868. return await asyncio.wait_for(loop.run_in_executor(None, _list), timeout=timeout)
  869. except TimeoutError:
  870. logger.warning("FTP list_files timed out after %ss for %s", timeout, path)
  871. return []
  872. async def delete_file_async(
  873. ip_address: str,
  874. access_code: str,
  875. remote_path: str,
  876. socket_timeout: float | None = None,
  877. printer_model: str | None = None,
  878. ) -> bool:
  879. """Async wrapper for deleting a file.
  880. Args:
  881. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  882. printer_model: Printer model for A1-specific workarounds
  883. """
  884. loop = asyncio.get_event_loop()
  885. def _delete():
  886. client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
  887. if client.connect():
  888. try:
  889. return client.delete_file(remote_path)
  890. finally:
  891. client.disconnect()
  892. return False
  893. return await loop.run_in_executor(None, _delete)
  894. async def download_file_bytes_async(
  895. ip_address: str,
  896. access_code: str,
  897. remote_path: str,
  898. socket_timeout: float | None = None,
  899. printer_model: str | None = None,
  900. ) -> bytes | None:
  901. """Async wrapper for downloading file as bytes.
  902. Args:
  903. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  904. printer_model: Printer model for A1-specific workarounds
  905. """
  906. loop = asyncio.get_event_loop()
  907. def _download():
  908. client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
  909. if client.connect():
  910. try:
  911. return client.download_file(remote_path)
  912. finally:
  913. client.disconnect()
  914. return None
  915. return await loop.run_in_executor(None, _download)
  916. async def get_storage_info_async(
  917. ip_address: str,
  918. access_code: str,
  919. socket_timeout: float | None = None,
  920. printer_model: str | None = None,
  921. ) -> dict | None:
  922. """Async wrapper for getting storage info.
  923. Args:
  924. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  925. printer_model: Printer model for A1-specific workarounds
  926. """
  927. loop = asyncio.get_event_loop()
  928. def _get_storage():
  929. client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
  930. if client.connect():
  931. try:
  932. return client.get_storage_info()
  933. finally:
  934. client.disconnect()
  935. return None
  936. return await loop.run_in_executor(None, _get_storage)
  937. async def get_ftp_retry_settings() -> tuple[bool, int, float, float]:
  938. """Get FTP retry settings from database.
  939. Returns:
  940. Tuple of (retry_enabled, retry_count, retry_delay, timeout)
  941. """
  942. from backend.app.api.routes.settings import get_setting
  943. from backend.app.core.database import async_session
  944. async with async_session() as db:
  945. enabled = (await get_setting(db, "ftp_retry_enabled") or "true") == "true"
  946. count = int(await get_setting(db, "ftp_retry_count") or "3")
  947. delay = float(await get_setting(db, "ftp_retry_delay") or "2")
  948. timeout = float(await get_setting(db, "ftp_timeout") or "30")
  949. return enabled, count, delay, timeout
  950. async def with_ftp_retry(
  951. operation: Callable[..., Awaitable[T]],
  952. *args,
  953. max_retries: int = 3,
  954. retry_delay: float = 2.0,
  955. operation_name: str = "FTP operation",
  956. non_retry_exceptions: tuple[type[BaseException], ...] = (),
  957. **kwargs,
  958. ) -> T | None:
  959. """Execute FTP operation with retry logic.
  960. Args:
  961. operation: Async function to execute
  962. *args: Positional arguments for the operation
  963. max_retries: Number of retry attempts (default: 3)
  964. retry_delay: Seconds to wait between retries (default: 2.0)
  965. operation_name: Name for logging purposes
  966. non_retry_exceptions: Exception types that should immediately abort retries
  967. **kwargs: Keyword arguments for the operation
  968. Returns:
  969. Result of the operation, or None if all attempts fail
  970. """
  971. last_error = None
  972. for attempt in range(max_retries + 1):
  973. try:
  974. result = await operation(*args, **kwargs)
  975. # Check for "falsy" success indicators
  976. if result not in (False, None, []):
  977. if attempt > 0:
  978. logger.info("%s succeeded on attempt %s/%s", operation_name, attempt + 1, max_retries + 1)
  979. return result
  980. # Operation returned failure indicator
  981. if attempt > 0:
  982. logger.info("%s attempt %s/%s returned failure", operation_name, attempt + 1, max_retries + 1)
  983. except Exception as e:
  984. if non_retry_exceptions and isinstance(e, non_retry_exceptions):
  985. raise
  986. last_error = e
  987. logger.warning("%s attempt %s/%s failed: %s", operation_name, attempt + 1, max_retries + 1, e)
  988. # Don't wait after the last attempt
  989. if attempt < max_retries:
  990. logger.info("%s will retry in %ss...", operation_name, retry_delay)
  991. await asyncio.sleep(retry_delay)
  992. logger.error("%s failed after %s attempts", operation_name, max_retries + 1)
  993. if last_error:
  994. logger.debug("Last error: %s", last_error)
  995. return None