bambu_ftp.py 57 KB

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