bambu_ftp.py 81 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860
  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 concurrent.futures import ThreadPoolExecutor
  12. from enum import Enum
  13. from ftplib import FTP, FTP_TLS # nosec B402
  14. from io import BytesIO
  15. from pathlib import Path
  16. from typing import TypeVar
  17. logger = logging.getLogger(__name__)
  18. T = TypeVar("T")
  19. # Every FTP call below is blocking ftplib work handed to a thread. They used to
  20. # run on asyncio's *default* executor, which is sized min(32, cpu_count + 4) —
  21. # six threads on a 2-core NAS — and is shared with every other ``to_thread`` /
  22. # ``run_in_executor`` caller in the app. That was survivable only because the
  23. # scheduler uploaded to exactly one printer at a time. Dispatching to several
  24. # printers at once (#2555) would park one thread per in-flight upload for
  25. # minutes at a stretch (a 41 MB 3MF at the ~150 KB/s a Bambu printer sustains
  26. # takes ~4 min), starving the default pool and stalling unrelated work.
  27. #
  28. # A dedicated pool keeps that blast radius inside the FTP layer: the scheduler's
  29. # own concurrency cap is what limits parallel uploads, and it can never exhaust
  30. # the executor everything else depends on. Threads are created lazily, so an
  31. # idle pool costs nothing.
  32. #
  33. # Sized well above `queue_max_concurrent_uploads` (max 16), because uploads are
  34. # not the only traffic here: SD browsing, timelapse/recording listing, cover
  35. # downloads, deletes and storage checks all run through this pool too, and on a
  36. # farm they fan out across every printer at once. The pool's work queue is
  37. # unbounded, so exceeding it does not fail — it queues. But `asyncio.wait_for`
  38. # starts its clock at submission, not at thread start, so a task that sits in the
  39. # queue can burn its whole timeout without ever running, and `list_files_async`
  40. # reports a timeout as an empty listing — a silent "this printer has no files".
  41. # Keep the headroom.
  42. _FTP_MAX_WORKERS = 48
  43. _ftp_executor = ThreadPoolExecutor(max_workers=_FTP_MAX_WORKERS, thread_name_prefix="bambu-ftp")
  44. # Overall upload deadline (#2529). A flat wall-clock cap punishes big files on
  45. # slow links rather than catching broken ones: a 96 MB 3MF at the ~75 KB/s an A1
  46. # sustains over WiFi legitimately needs ~20 minutes, and the old flat 600 s
  47. # declared it dead at ~70 MB. The deadline is therefore derived from the file
  48. # size against a deliberately pessimistic floor rate. This is a backstop, not the
  49. # failure detector — a link that has actually died is caught within
  50. # ``socket_timeout`` by the blocking ``sendall``, long before this fires.
  51. _UPLOAD_FLOOR_BYTES_PER_SEC = 25 * 1024
  52. _UPLOAD_MIN_TIMEOUT = 600.0
  53. # How long to give the worker thread to notice the cancel flag, unwind, and
  54. # delete its partial file. It checks the flag once per CHUNK_SIZE, so on a link
  55. # slow enough to have hit the deadline this is one chunk plus the delete.
  56. _UPLOAD_CANCEL_GRACE = 60.0
  57. class UploadCancelled(Exception):
  58. """Raised inside the upload worker to abort an in-flight transfer.
  59. ``upload_file`` treats any exception from its progress callback as "stop
  60. now": it breaks out of the send loop, deletes the partial file from the
  61. printer, and re-raises. That is the only way to stop a transfer — an
  62. executor thread cannot be cancelled from the event loop, so a bare
  63. ``asyncio.wait_for`` leaves it streaming (see ``upload_file_async``).
  64. """
  65. class DeleteResult(Enum):
  66. """Outcome of an FTP delete attempt.
  67. Distinguishes "file isn't on the printer" (550, recovery impossible by
  68. retrying) from "delete failed for some other reason" (network, auth,
  69. transient FTP error — worth retrying). The post-print SD-card cleanup in
  70. main.py used to flatten both into ``False`` and log a "may linger" WARNING
  71. on every successful print where the printer self-cleaned its SD card
  72. before our cleanup ran (#1721 reporter's A1).
  73. """
  74. DELETED = "deleted"
  75. NOT_FOUND = "not_found"
  76. FAILED = "failed"
  77. # How long to stop opening FTPS connections to a printer after its TLS
  78. # handshake failed (#2780).
  79. #
  80. # ``WRONG_VERSION_NUMBER`` on port 990 means the printer answered with
  81. # something that is not a TLS record at all, so no path, retry or SSL option
  82. # gets further. Two support bundles show that state lasting for days: one X2D
  83. # served clean FTPS for five days, flipped on 2026-07-19, and then failed every
  84. # single handshake for the next eight (zero successes, 3511 failures).
  85. #
  86. # What it is NOT is a wedged file service, which is what this comment used to
  87. # claim. #2780's reporter power-cycled both affected printers and the state
  88. # survived it, and ``openssl s_client`` against the same port completes a clean
  89. # handshake and returns a valid certificate while Bambuddy is failing. The
  90. # leading theory is now a connection-count refusal — vsFTPd answers one in
  91. # cleartext, which is exactly this error to an implicit-TLS client, and answers
  92. # the global limit by accepting and never speaking, which is the handshake
  93. # timeout we also see. Unproven: confirming it needs a capture taken while a
  94. # printer is in the failing state.
  95. #
  96. # Without a gate every candidate path re-runs the same doomed handshake: the
  97. # 3MF lookup alone walks 6 filename variants x 5 directories x 4 retries, and
  98. # the cover and timelapse scans run their own sweeps on top. That is where
  99. # those thousands of failures come from — one wedged printer, hammered.
  100. #
  101. # Five minutes is short enough that a power-cycled printer is picked up on the
  102. # next print (and any successful connect clears the gate immediately), long
  103. # enough that a wedged one is contacted twice an hour instead of hundreds of
  104. # times a minute.
  105. _HANDSHAKE_COOLOFF_SECONDS = 300.0
  106. class FileNotOnPrinterError(Exception):
  107. """Raised when a remote FTP path returns 550 (file not found).
  108. 550 means the file does not exist at that path — retrying the same path
  109. will never succeed. Callers use this sentinel with with_ftp_retry's
  110. non_retry_exceptions to immediately move on to the next candidate path
  111. instead of burning the full retry budget (up to 11 × 30s per path) on
  112. a lookup that cannot recover.
  113. """
  114. class ImplicitFTP_TLS(FTP_TLS):
  115. """FTP_TLS subclass for implicit FTPS (port 990) with model-specific SSL handling.
  116. X1C/P1S printers (vsFTPd) require SSL with session reuse on the data channel.
  117. A1/A1 Mini printers have issues with SSL on the data channel entirely and
  118. timeout waiting for transfer completion. Set skip_session_reuse=True for A1
  119. printers to skip SSL on the data channel (control channel remains encrypted).
  120. Optionally caps the SSL context's maximum TLS version to v1.2 (P2S firmware
  121. 01.02.00.00 needs this — see :mod:`ftp_profiles` and #1401).
  122. """
  123. def __init__(self, *args, skip_session_reuse: bool = False, cap_tls_v1_2: bool = False, **kwargs):
  124. super().__init__(*args, **kwargs)
  125. self._sock = None
  126. self.skip_session_reuse = skip_session_reuse
  127. self.ssl_context = ssl.create_default_context()
  128. self.ssl_context.check_hostname = False
  129. self.ssl_context.verify_mode = ssl.CERT_NONE
  130. # ``create_default_context()`` does NOT guarantee a protocol floor: it
  131. # leaves ``minimum_version`` at ``MINIMUM_SUPPORTED``, and what that
  132. # resolves to is a property of the OpenSSL build, not of this code.
  133. # Measured on identical OpenSSL 3.5.6: python:3.13-slim-trixie (our
  134. # Docker base) reports TLSv1_2, a bare-metal venv reports
  135. # MINIMUM_SUPPORTED. Docker users have therefore always been floored at
  136. # 1.2 — every Bambu model is reachable under that floor — while
  137. # bare-metal and appliance installs could silently negotiate TLS 1.0.
  138. # State the floor rather than inheriting it.
  139. self.ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
  140. if cap_tls_v1_2:
  141. # With the floor above this pins the connection to exactly TLS 1.2.
  142. self.ssl_context.maximum_version = ssl.TLSVersion.TLSv1_2
  143. def connect(self, host="", port=990, timeout=-999, source_address=None):
  144. """Connect to host, wrapping socket in TLS immediately (implicit FTPS)."""
  145. if host:
  146. self.host = host
  147. if port > 0:
  148. self.port = port
  149. if timeout != -999:
  150. self.timeout = timeout
  151. if source_address:
  152. self.source_address = source_address
  153. # Create and wrap socket immediately (implicit TLS)
  154. self.sock = socket.create_connection((self.host, self.port), self.timeout, source_address=self.source_address)
  155. self.sock = self.ssl_context.wrap_socket(self.sock, server_hostname=self.host)
  156. self.af = self.sock.family
  157. self.file = self.sock.makefile("r", encoding=self.encoding)
  158. self.welcome = self.getresp()
  159. return self.welcome
  160. def ntransfercmd(self, cmd, rest=None):
  161. """Override to wrap data connection in SSL for X1C/P1S only.
  162. X1C/P1S printers (vsFTPd) require SSL session reuse on the data channel.
  163. A1/A1 Mini printers have issues with SSL on the data channel entirely -
  164. they timeout waiting for the transfer completion response. For A1, we
  165. skip SSL wrapping on the data channel (control channel remains encrypted).
  166. """
  167. conn, size = FTP.ntransfercmd(self, cmd, rest)
  168. if self._prot_p and not self.skip_session_reuse:
  169. # X1C/P1S: Wrap data channel with SSL session reuse (required by vsFTPd)
  170. conn = self.ssl_context.wrap_socket(
  171. conn,
  172. server_hostname=self.host,
  173. session=self.sock.session,
  174. )
  175. # A1/A1 Mini (skip_session_reuse=True): Don't wrap data channel in SSL
  176. # The control channel remains encrypted via implicit FTPS
  177. return conn, size
  178. class BambuFTPClient:
  179. """FTP client for retrieving files from Bambu Lab printers."""
  180. FTP_PORT = 990
  181. # Default timeout in seconds (increased for A1 printers)
  182. DEFAULT_TIMEOUT = 30
  183. # Models that may need SSL mode fallback (try prot_p first, fall back to prot_c)
  184. # These models have varying FTP SSL behavior depending on firmware version
  185. A1_MODELS = ("A1", "A1 Mini")
  186. # Chunk size for manual upload transfer (64KB)
  187. # Smaller chunks provide smoother progress reporting — at typical printer FTP
  188. # speeds (~50-100KB/s) this gives a progress update roughly every second.
  189. CHUNK_SIZE = 64 * 1024
  190. # Cache for working FTP modes per printer IP
  191. # Maps IP -> "prot_p" or "prot_c"
  192. _mode_cache: dict[str, str] = {}
  193. # Printers whose FTPS handshake just failed, mapped to the monotonic time
  194. # their cool-off expires. See ``_HANDSHAKE_COOLOFF_SECONDS``.
  195. _handshake_blocked_until: dict[str, float] = {}
  196. # Which cool-off deadline each printer's "not attempted" warning was last
  197. # logged for, so the warning is said once per cool-off. See ``connect``.
  198. _handshake_skip_logged: dict[str, float] = {}
  199. def __init__(
  200. self,
  201. ip_address: str,
  202. access_code: str,
  203. timeout: float | None = None,
  204. printer_model: str | None = None,
  205. force_prot_c: bool = False,
  206. respect_handshake_cooloff: bool = True,
  207. ):
  208. """Set ``respect_handshake_cooloff=False`` for bounded, user-initiated work.
  209. The cool-off exists to stop an unbounded sweep re-running one doomed
  210. handshake a hundred times over (#2780). Dispatching a print is not
  211. that: it is one delete plus at most four upload attempts, with someone
  212. waiting on the result. Sharing the sweep's gate cost those attempts
  213. their whole retry budget, and failed every further job queued for that
  214. printer for the rest of the 300s window (#2898).
  215. Leave it at the default everywhere else. Opting out is only defensible
  216. because the caller's own connection count is bounded and small.
  217. """
  218. self.ip_address = ip_address
  219. self.access_code = access_code
  220. self.timeout = timeout if timeout is not None else self.DEFAULT_TIMEOUT
  221. self.printer_model = printer_model
  222. self.force_prot_c = force_prot_c
  223. self.respect_handshake_cooloff = respect_handshake_cooloff
  224. self._ftp: ImplicitFTP_TLS | None = None
  225. def _is_a1_model(self) -> bool:
  226. """Check if this is an A1 series printer."""
  227. if not self.printer_model:
  228. return False
  229. return self.printer_model in self.A1_MODELS
  230. def _get_cached_mode(self) -> str | None:
  231. """Get cached FTP mode for this printer."""
  232. return self._mode_cache.get(self.ip_address)
  233. @classmethod
  234. def cache_mode(cls, ip_address: str, mode: str):
  235. """Cache the working FTP mode for a printer."""
  236. cls._mode_cache[ip_address] = mode
  237. logger.info("FTP mode cached for %s: %s", ip_address, mode)
  238. def _should_use_prot_c(self) -> bool:
  239. """Determine if we should use prot_c (clear) mode."""
  240. # If explicitly forced, use prot_c
  241. if self.force_prot_c:
  242. return True
  243. # Check cache first
  244. cached = self._get_cached_mode()
  245. if cached:
  246. return cached == "prot_c"
  247. # Default: try prot_p first (will fall back if needed)
  248. return False
  249. @classmethod
  250. def handshake_blocked(cls, ip_address: str) -> bool:
  251. """True while *ip_address* is inside its post-handshake-failure cool-off.
  252. Public so a caller sweeping many candidate paths can stop after the
  253. first one rather than walking the rest against a printer that cannot
  254. complete a TLS handshake (#2780).
  255. """
  256. deadline = cls._handshake_blocked_until.get(ip_address)
  257. if deadline is None:
  258. return False
  259. if time.monotonic() >= deadline:
  260. # Drop it on the way past rather than leaving an entry per printer
  261. # this process has ever failed against.
  262. del cls._handshake_blocked_until[ip_address]
  263. cls._handshake_skip_logged.pop(ip_address, None)
  264. return False
  265. return True
  266. def connect(self) -> bool:
  267. """Connect to the printer FTP server (implicit FTPS on port 990).
  268. Returns False without touching the network while the printer is inside
  269. the cool-off a previous TLS handshake failure opened (#2780) -- unless
  270. this client was built with ``respect_handshake_cooloff=False``.
  271. """
  272. if self.respect_handshake_cooloff and self.handshake_blocked(self.ip_address):
  273. # WARNING, not DEBUG. This is the one connect() failure path that
  274. # reported without its cause, so at default log level four
  275. # reason-free "FTP connection failed" lines two seconds apart gave
  276. # no hint that nothing had been sent (#2898). Every caller reaching
  277. # here is already gated by handshake_blocked() at its own sweep
  278. # boundary, so this costs about one line per print, not a flood.
  279. deadline = self._handshake_blocked_until.get(self.ip_address)
  280. remaining = max(0.0, deadline - time.monotonic()) if deadline is not None else 0.0
  281. if deadline is not None and self._handshake_skip_logged.get(self.ip_address) != deadline:
  282. self._handshake_skip_logged[self.ip_address] = deadline
  283. logger.warning(
  284. "FTP connect to %s not attempted: its FTPS handshake failed recently and it is "
  285. "cooling off for another %.0fs. Nothing was sent to the printer.",
  286. self.ip_address,
  287. remaining,
  288. )
  289. else:
  290. # Said once already for this cool-off. Repeating it per candidate
  291. # path is the log flood #2780 set out to stop -- a download-zip
  292. # of 200 files would print the same sentence 200 times.
  293. logger.debug(
  294. "FTP connect to %s skipped: still cooling off for another %.0fs",
  295. self.ip_address,
  296. remaining,
  297. )
  298. return False
  299. try:
  300. use_prot_c = self._should_use_prot_c()
  301. from backend.app.services.ftp_profiles import get_ftp_profile
  302. profile = get_ftp_profile(self.printer_model)
  303. logger.debug(
  304. f"FTP connecting to {self.ip_address}:{self.FTP_PORT} "
  305. f"(timeout={self.timeout}s, model={self.printer_model}, prot_c={use_prot_c}, "
  306. f"cap_tls_v1_2={profile.cap_tls_v1_2})"
  307. )
  308. self._ftp = ImplicitFTP_TLS(
  309. skip_session_reuse=use_prot_c,
  310. cap_tls_v1_2=profile.cap_tls_v1_2,
  311. )
  312. self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=self.timeout)
  313. logger.debug("FTP connected, logging in as bblp")
  314. self._ftp.login("bblp", self.access_code)
  315. if use_prot_c:
  316. # Use clear (unencrypted) data channel
  317. logger.debug("FTP logged in, setting prot_c (clear) and passive mode")
  318. self._ftp.prot_c()
  319. else:
  320. # Use protected (encrypted) data channel with session reuse
  321. logger.debug("FTP logged in, setting prot_p (protected) and passive mode")
  322. self._ftp.prot_p()
  323. self._ftp.set_pasv(True)
  324. # Log welcome message for debugging
  325. if hasattr(self._ftp, "welcome") and self._ftp.welcome:
  326. logger.debug("FTP server welcome: %s", self._ftp.welcome)
  327. logger.info(
  328. f"FTP connected successfully to {self.ip_address} (model={self.printer_model}, prot_c={use_prot_c})"
  329. )
  330. return True
  331. except ftplib.error_perm as e:
  332. logger.warning("FTP connection permission error to %s: %s", self.ip_address, e)
  333. self._abandon_connection()
  334. return False
  335. except TimeoutError as e:
  336. logger.warning("FTP connection timed out to %s: %s", self.ip_address, e)
  337. self._abandon_connection()
  338. return False
  339. except ssl.SSLError as e:
  340. # Not a transient failure and not something another path or another
  341. # retry can route around: the printer's file service answered port
  342. # 990 with something that isn't TLS. Say so once and stop knocking
  343. # for a while (#2780).
  344. #
  345. # Deliberately no advice about what to do. This message used to
  346. # tell the operator to restart the printer; #2780's reporter did
  347. # that twice, to no effect, and a single manual connect to the
  348. # same printer completes a clean handshake. We do not yet know the
  349. # trigger, so stating the observation and stopping there beats
  350. # sending people to do the one thing already known not to work.
  351. logger.warning(
  352. "FTP SSL error connecting to %s: %s — the printer answered port %s with something "
  353. "that is not TLS, so print files, covers and timelapses cannot be fetched from it. "
  354. "Pausing FTP to this printer for %.0fs.",
  355. self.ip_address,
  356. e,
  357. self.FTP_PORT,
  358. _HANDSHAKE_COOLOFF_SECONDS,
  359. )
  360. self._handshake_blocked_until[self.ip_address] = time.monotonic() + _HANDSHAKE_COOLOFF_SECONDS
  361. self._abandon_connection()
  362. return False
  363. except (OSError, ftplib.Error) as e:
  364. logger.warning("FTP connection failed to %s: %s (type: %s)", self.ip_address, e, type(e).__name__)
  365. self._abandon_connection()
  366. return False
  367. def _abandon_connection(self) -> None:
  368. """Drop a connection that never became usable, closing its socket.
  369. Every failure path in :meth:`connect` used to clear ``self._ftp`` and
  370. nothing else, leaving a connected socket for the garbage collector.
  371. That is survivable once; it is not survivable at this volume. A single
  372. print used to walk ~110 candidate paths, so a printer refusing FTPS
  373. got ~110 sockets opened and abandoned in a couple of minutes, and one
  374. support bundle recorded 1813 of them in a day (#2780). If the refusal
  375. is the printer running out of connection slots -- which fits the
  376. evidence better than a wedged service, since a single manual connect
  377. to the same printer succeeds -- then abandoning sockets is not just
  378. untidy, it is what keeps the printer refusing.
  379. Uses ``close()`` rather than ``quit()``: QUIT is a command, and there
  380. is no working control channel to send it on.
  381. """
  382. ftp = self._ftp
  383. self._ftp = None
  384. if ftp is None:
  385. return
  386. try:
  387. ftp.close()
  388. except (OSError, ftplib.Error, EOFError):
  389. pass # Best-effort; the socket may already be gone
  390. def disconnect(self):
  391. """Disconnect from the FTP server."""
  392. if self._ftp:
  393. try:
  394. self._ftp.quit()
  395. except (OSError, ftplib.Error, EOFError):
  396. # ``quit()`` sends QUIT and only then closes; when the send
  397. # raises, ftplib never reaches its own close and the socket
  398. # stays open. Close it here rather than leaving it to the GC.
  399. self._abandon_connection()
  400. self._ftp = None
  401. def list_files(self, path: str = "/") -> list[dict]:
  402. """List files in a directory."""
  403. if not self._ftp:
  404. return []
  405. files = []
  406. try:
  407. self._ftp.cwd(path)
  408. items = []
  409. self._ftp.retrlines("LIST", items.append)
  410. for item in items:
  411. parts = item.split()
  412. if len(parts) >= 9:
  413. name = " ".join(parts[8:])
  414. is_dir = item.startswith("d")
  415. size = int(parts[4]) if not is_dir else 0
  416. # Parse modification time from FTP listing
  417. # Format: "Nov 30 10:15" or "Nov 30 2024"
  418. mtime = None
  419. try:
  420. from datetime import datetime
  421. month = parts[5]
  422. day = parts[6]
  423. time_or_year = parts[7]
  424. # Determine if it's time (HH:MM) or year
  425. if ":" in time_or_year:
  426. # Recent file: "Nov 30 10:15" - assume current year
  427. year = datetime.now().year
  428. time_str = f"{month} {day} {year} {time_or_year}"
  429. mtime = datetime.strptime(time_str, "%b %d %Y %H:%M")
  430. # If parsed date is in the future, use last year
  431. if mtime > datetime.now():
  432. mtime = mtime.replace(year=year - 1)
  433. else:
  434. # Older file: "Nov 30 2024" - no time, just date
  435. time_str = f"{month} {day} {time_or_year}"
  436. mtime = datetime.strptime(time_str, "%b %d %Y")
  437. except (ValueError, IndexError):
  438. pass # Non-critical: mtime parsing is best-effort; file entry works without it
  439. file_entry = {
  440. "name": name,
  441. "is_directory": is_dir,
  442. "size": size,
  443. "path": f"{path.rstrip('/')}/{name}",
  444. }
  445. if mtime:
  446. file_entry["mtime"] = mtime
  447. files.append(file_entry)
  448. logger.debug("Listed %s files in %s", len(files), path)
  449. except (OSError, ftplib.Error) as e:
  450. logger.info("FTP list_files failed for %s: %s", path, e)
  451. return files
  452. def download_file(self, remote_path: str, expected_size: int | None = None) -> bytes | None:
  453. """Download a file from the printer.
  454. ``expected_size`` is the byte count the directory listing reported for
  455. this file. Pass it whenever a short read must not be mistaken for a
  456. successful download: an FTPS data connection that closes early does
  457. not always raise, so ``retrbinary`` can hand back a partial buffer that
  458. looks like a perfectly good file to everything downstream. That is
  459. tolerable when the printer keeps its copy, and not tolerable when the
  460. caller goes on to delete the source (#2704).
  461. A zero-byte result is always treated as a failure, matching
  462. :meth:`download_to_file` — no caller has a use for an empty file.
  463. """
  464. if not self._ftp:
  465. return None
  466. try:
  467. buffer = BytesIO()
  468. self._ftp.retrbinary(f"RETR {remote_path}", buffer.write)
  469. data = buffer.getvalue()
  470. except (OSError, ftplib.Error):
  471. return None
  472. if not data:
  473. logger.warning("FTP download returned 0 bytes for %s", remote_path)
  474. return None
  475. if expected_size is not None and len(data) != expected_size:
  476. logger.warning(
  477. "FTP download of %s is short: got %s bytes, listing reported %s — treating as failed",
  478. remote_path,
  479. len(data),
  480. expected_size,
  481. )
  482. return None
  483. return data
  484. def download_to_file(self, remote_path: str, local_path: Path) -> bool:
  485. """Download a file from the printer to local filesystem."""
  486. if not self._ftp:
  487. logger.warning("download_to_file called but FTP not connected")
  488. return False
  489. try:
  490. local_path.parent.mkdir(parents=True, exist_ok=True)
  491. with open(local_path, "wb") as f:
  492. self._ftp.retrbinary(f"RETR {remote_path}", f.write)
  493. f.flush()
  494. os.fsync(f.fileno())
  495. file_size = local_path.stat().st_size if local_path.exists() else 0
  496. if file_size == 0:
  497. logger.warning("FTP download returned 0 bytes for %s", remote_path)
  498. if local_path.exists():
  499. local_path.unlink()
  500. return False
  501. logger.info("Successfully downloaded %s to %s (%s bytes)", remote_path, local_path, file_size)
  502. return True
  503. except (OSError, ftplib.Error) as e:
  504. # Clean up partial file if it exists
  505. if local_path.exists():
  506. try:
  507. local_path.unlink()
  508. except OSError:
  509. pass # Best-effort partial file cleanup; not critical if removal fails
  510. # 550 means the file is not at this path. Surface as a sentinel so
  511. # with_ftp_retry can abandon this path immediately and the caller
  512. # can advance to the next candidate instead of retrying 11× at
  513. # 30s intervals (the pattern that cost #972's reporter ~48min).
  514. if isinstance(e, ftplib.error_perm) and str(e).startswith("550"):
  515. logger.info("FTP download failed for %s: %s (not on printer)", remote_path, e)
  516. raise FileNotOnPrinterError(f"{remote_path}: {e}") from e
  517. # Log at INFO level so we can see failures in normal logs
  518. logger.info("FTP download failed for %s: %s", remote_path, e)
  519. return False
  520. def diagnose_storage(self) -> dict:
  521. """Run storage diagnostics and return results. For debugging upload issues."""
  522. results = {
  523. "connected": self._ftp is not None,
  524. "can_list_root": False,
  525. "root_files": [],
  526. "can_list_cache": False,
  527. "storage_info": None,
  528. "pwd": None,
  529. "errors": [],
  530. }
  531. if not self._ftp:
  532. results["errors"].append("FTP not connected")
  533. return results
  534. # Try to get current directory
  535. try:
  536. results["pwd"] = self._ftp.pwd()
  537. logger.debug("FTP current directory: %s", results["pwd"])
  538. except (OSError, ftplib.Error) as e:
  539. results["errors"].append(f"PWD failed: {e}")
  540. logger.debug("FTP PWD failed: %s", e)
  541. # Try to list root directory
  542. try:
  543. self._ftp.cwd("/")
  544. items = []
  545. self._ftp.retrlines("LIST", items.append)
  546. results["can_list_root"] = True
  547. results["root_files"] = items[:10] # First 10 entries
  548. logger.debug("FTP root listing (%s items): %s", len(items), items[:5])
  549. except (OSError, ftplib.Error) as e:
  550. results["errors"].append(f"LIST / failed: {e}")
  551. logger.debug("FTP LIST / failed: %s", e)
  552. # Try to list /cache (should exist on all printers)
  553. try:
  554. self._ftp.cwd("/cache")
  555. items = []
  556. self._ftp.retrlines("LIST", items.append)
  557. results["can_list_cache"] = True
  558. logger.debug("FTP /cache listing: %s items", len(items))
  559. except (OSError, ftplib.Error) as e:
  560. results["errors"].append(f"LIST /cache failed: {e}")
  561. logger.debug("FTP LIST /cache failed: %s", e)
  562. # Try to get storage info
  563. try:
  564. results["storage_info"] = self.get_storage_info()
  565. logger.debug("FTP storage info: %s", results["storage_info"])
  566. except (OSError, ftplib.Error) as e:
  567. results["errors"].append(f"Storage info failed: {e}")
  568. return results
  569. def upload_file(
  570. self,
  571. local_path: Path,
  572. remote_path: str,
  573. progress_callback: Callable[[int, int], None] | None = None,
  574. ) -> bool:
  575. """Upload a file to the printer with optional progress callback."""
  576. if not self._ftp:
  577. logger.warning("upload_file: FTP not connected")
  578. return False
  579. try:
  580. file_size = local_path.stat().st_size if local_path.exists() else 0
  581. logger.info("FTP uploading %s (%s bytes) to %s", local_path, file_size, remote_path)
  582. uploaded = 0
  583. callback_exception: Exception | None = None
  584. # Use manual transfer instead of storbinary() for A1 compatibility
  585. # A1 printers have issues with storbinary's voidresp() hanging after transfer
  586. with open(local_path, "rb") as f:
  587. logger.debug("FTP STOR command starting for %s", remote_path)
  588. t0 = time.monotonic()
  589. conn = self._ftp.transfercmd(f"STOR {remote_path}")
  590. logger.info(
  591. "FTP data channel ready in %.1fs (PASV + TLS handshake)",
  592. time.monotonic() - t0,
  593. )
  594. # Set explicit socket options for reliable transfer
  595. conn.setblocking(True)
  596. conn.settimeout(self.timeout)
  597. try:
  598. while True:
  599. chunk = f.read(self.CHUNK_SIZE)
  600. if not chunk:
  601. logger.debug("FTP upload: final chunk reached")
  602. break
  603. conn.sendall(chunk)
  604. uploaded += len(chunk)
  605. logger.debug("FTP upload progress: %s/%s bytes", uploaded, file_size)
  606. if progress_callback:
  607. try:
  608. progress_callback(uploaded, file_size)
  609. except Exception as e:
  610. callback_exception = e
  611. logger.info(
  612. "FTP upload callback requested stop for %s at %s/%s bytes: %s",
  613. remote_path,
  614. uploaded,
  615. file_size,
  616. e,
  617. )
  618. break
  619. except OSError as e:
  620. logger.error("FTP connection lost during upload: %s", e)
  621. raise
  622. finally:
  623. try:
  624. conn.close()
  625. except OSError:
  626. pass
  627. # Wait for the server's 226 "Transfer complete" response to confirm
  628. # the file has been flushed to the SD card. Without this, the printer
  629. # may try to read an incomplete file when the print command is sent,
  630. # causing 0500-C010 "MicroSD Card read/write exception" errors.
  631. # See: https://bugs.python.org/issue25458 (ftplib response desync)
  632. try:
  633. old_timeout = self._ftp.sock.gettimeout()
  634. # Use a generous timeout — H2D printers can take 30+ seconds
  635. # to send the 226 after the data channel closes.
  636. self._ftp.sock.settimeout(max(self.timeout, 60))
  637. try:
  638. resp = self._ftp.voidresp()
  639. logger.info("FTP STOR confirmed for %s: %s", remote_path, resp.strip())
  640. finally:
  641. self._ftp.sock.settimeout(old_timeout)
  642. except ftplib.Error as e:
  643. # Some P2S firmware revisions return ftplib.Error (e.g. 426
  644. # "Failure reading network stream") on voidresp() even when
  645. # the file landed fully on the SD card — the TLS data
  646. # channel close races the 226 confirmation (#1417 follow-up).
  647. # Verify via SIZE: if the server-side file size matches what
  648. # we just uploaded, the file is intact and we proceed with
  649. # a warning. If not — or SIZE itself fails — the transfer
  650. # was genuinely truncated and we must fail so the print
  651. # command doesn't go out for a partial 3MF (the original
  652. # reason this catch was tightened in the previous round).
  653. try:
  654. server_size = self._ftp.size(remote_path)
  655. except (OSError, ftplib.Error) as size_err:
  656. logger.debug("Post-error SIZE check failed: %s", size_err)
  657. server_size = None
  658. if server_size is not None and server_size == file_size:
  659. logger.warning(
  660. "FTP STOR returned %s for %s but file is intact on the "
  661. "printer (%s bytes match) — proceeding: %s",
  662. type(e).__name__,
  663. remote_path,
  664. file_size,
  665. e,
  666. )
  667. else:
  668. logger.error(
  669. "FTP STOR rejected by printer for %s: %s (%s); server size=%s expected=%s",
  670. remote_path,
  671. e,
  672. type(e).__name__,
  673. server_size,
  674. file_size,
  675. )
  676. raise
  677. except Exception as e:
  678. # Timeout or socket-level error reading 226 — the data was sent
  679. # on our side and the printer may still have written the file.
  680. # H2D can take 30+ seconds to send 226 after the data channel
  681. # closes, so we proceed with a warning rather than failing here.
  682. logger.warning(
  683. "FTP STOR confirmation not received for %s (proceeding): %s (%s)",
  684. remote_path,
  685. e,
  686. type(e).__name__,
  687. )
  688. if callback_exception is not None:
  689. cleanup_result: DeleteResult = DeleteResult.FAILED
  690. try:
  691. cleanup_result = self.delete_file(remote_path)
  692. except Exception as cleanup_error:
  693. logger.warning("FTP cancel cleanup failed for %s: %s", remote_path, cleanup_error)
  694. # NOT_FOUND is success here — the partial file is gone (printer
  695. # may have already swept on cancel), which is the goal.
  696. if cleanup_result in (DeleteResult.DELETED, DeleteResult.NOT_FOUND):
  697. logger.info("FTP cancel cleanup succeeded for %s (%s)", remote_path, cleanup_result.value)
  698. raise callback_exception
  699. raise RuntimeError(
  700. f"Upload cancelled but failed to remove partial file {remote_path} from printer"
  701. ) from callback_exception
  702. elapsed = time.monotonic() - t0
  703. speed_kbs = (file_size / 1024) / elapsed if elapsed > 0 else 0
  704. logger.info(
  705. "FTP upload complete: %s (%s bytes in %.1fs, %.0f KB/s)",
  706. remote_path,
  707. file_size,
  708. elapsed,
  709. speed_kbs,
  710. )
  711. return True
  712. except ftplib.error_perm as e:
  713. # Permanent FTP error (4xx/5xx response)
  714. error_code = str(e)[:3] if str(e) else "unknown"
  715. logger.error("FTP upload failed for %s: %s (error code: %s)", remote_path, e, error_code)
  716. if error_code == "553":
  717. logger.error(
  718. "FTP 553 error - Could not create file. Possible causes: "
  719. "1) No SD card inserted, 2) SD card full, 3) SD card not formatted correctly (needs FAT32/exFAT), "
  720. "4) Printer busy/not ready, 5) File path issue"
  721. )
  722. elif error_code == "550":
  723. logger.error("FTP 550 error - File/directory not found or permission denied")
  724. elif error_code == "552":
  725. logger.error("FTP 552 error - Storage quota exceeded (SD card full?)")
  726. return False
  727. except (OSError, ftplib.Error) as e:
  728. logger.error("FTP upload failed for %s: %s (type: %s)", remote_path, e, type(e).__name__)
  729. return False
  730. def upload_bytes(self, data: bytes, remote_path: str) -> bool:
  731. """Upload bytes to the printer."""
  732. if not self._ftp:
  733. return False
  734. try:
  735. # Use manual transfer instead of storbinary() for A1 compatibility
  736. conn = self._ftp.transfercmd(f"STOR {remote_path}")
  737. conn.setblocking(True)
  738. conn.settimeout(self.timeout)
  739. try:
  740. # Send data in chunks
  741. offset = 0
  742. while offset < len(data):
  743. chunk = data[offset : offset + self.CHUNK_SIZE]
  744. conn.sendall(chunk)
  745. offset += len(chunk)
  746. except OSError as e:
  747. logger.error("FTP connection lost during upload_bytes: %s", e)
  748. raise
  749. finally:
  750. try:
  751. conn.close()
  752. except OSError:
  753. pass
  754. # Wait for 226 confirmation (see upload_file for rationale).
  755. # ftplib.Error subclasses (e.g. 426 error_temp) mean the server
  756. # rejected the transfer and the file is partial — fail. Other
  757. # exceptions (timeout, socket-level) are tolerated as in upload_file.
  758. try:
  759. old_timeout = self._ftp.sock.gettimeout()
  760. self._ftp.sock.settimeout(max(self.timeout, 60))
  761. try:
  762. self._ftp.voidresp()
  763. finally:
  764. self._ftp.sock.settimeout(old_timeout)
  765. except ftplib.Error as e:
  766. # Same SIZE-verify path as upload_file (#1417 follow-up):
  767. # tolerate a transient 426 if the bytes are actually on the
  768. # printer, fail loudly if they aren't.
  769. try:
  770. server_size = self._ftp.size(remote_path)
  771. except (OSError, ftplib.Error) as size_err:
  772. logger.debug("Post-error SIZE check failed: %s", size_err)
  773. server_size = None
  774. if server_size is not None and server_size == len(data):
  775. logger.warning(
  776. "FTP STOR returned %s for %s but file is intact on the "
  777. "printer (%s bytes match) — proceeding: %s",
  778. type(e).__name__,
  779. remote_path,
  780. len(data),
  781. e,
  782. )
  783. else:
  784. logger.error(
  785. "FTP STOR rejected by printer for %s: %s (%s); server size=%s expected=%s",
  786. remote_path,
  787. e,
  788. type(e).__name__,
  789. server_size,
  790. len(data),
  791. )
  792. return False
  793. except Exception:
  794. pass # Timeout / socket-level — proceed, data was sent.
  795. return True
  796. except (OSError, ftplib.Error):
  797. return False
  798. def delete_file(self, remote_path: str) -> DeleteResult:
  799. """Delete a file from the printer.
  800. Returns :class:`DeleteResult` distinguishing the file-not-found case
  801. (550) from network / auth / transient FTP failure. Callers that just
  802. want "did it work" should check ``result == DeleteResult.DELETED``.
  803. """
  804. if not self._ftp:
  805. return DeleteResult.FAILED
  806. try:
  807. self._ftp.delete(remote_path)
  808. return DeleteResult.DELETED
  809. except ftplib.error_perm as e:
  810. if str(e).startswith("550"):
  811. logger.debug("FTP delete: %s not on printer (550)", remote_path)
  812. return DeleteResult.NOT_FOUND
  813. logger.warning("Failed to delete %s: %s", remote_path, e)
  814. return DeleteResult.FAILED
  815. except (OSError, ftplib.Error) as e:
  816. logger.warning("Failed to delete %s: %s", remote_path, e)
  817. return DeleteResult.FAILED
  818. def get_file_size(self, remote_path: str) -> int | None:
  819. """Get the size of a file."""
  820. if not self._ftp:
  821. return None
  822. try:
  823. return self._ftp.size(remote_path)
  824. except (OSError, ftplib.Error):
  825. return None
  826. def get_storage_info(self) -> dict | None:
  827. """Get storage information from the printer."""
  828. if not self._ftp:
  829. return None
  830. result = {}
  831. # Try AVBL command (available space) - some FTP servers support this
  832. try:
  833. response = self._ftp.sendcmd("AVBL")
  834. logger.debug("AVBL response: %s", response)
  835. # Response format: "213 <bytes available>"
  836. if response.startswith("213"):
  837. parts = response.split()
  838. if len(parts) >= 2:
  839. result["free_bytes"] = int(parts[1])
  840. except (OSError, ftplib.Error) as e:
  841. logger.debug("AVBL command not supported: %s", e)
  842. # Try STAT command as fallback
  843. try:
  844. response = self._ftp.sendcmd("STAT")
  845. logger.debug("STAT response: %s", response)
  846. except (OSError, ftplib.Error):
  847. pass # Both AVBL and STAT unsupported; storage info will rely on directory scan
  848. # Calculate used space by listing root directories
  849. try:
  850. total_used = 0
  851. dirs_to_scan = ["/cache", "/timelapse", "/model", "/data", "/data/Metadata", "/"]
  852. for dir_path in dirs_to_scan:
  853. try:
  854. self._ftp.cwd(dir_path)
  855. items = []
  856. self._ftp.retrlines("LIST", items.append)
  857. for item in items:
  858. parts = item.split()
  859. if len(parts) >= 5 and not item.startswith("d"):
  860. try:
  861. total_used += int(parts[4])
  862. except ValueError:
  863. pass # Skip entries with non-numeric size fields
  864. except (OSError, ftplib.Error):
  865. pass # Directory may not exist on this printer model; skip it
  866. result["used_bytes"] = total_used
  867. except (OSError, ftplib.Error):
  868. pass # Storage scan failed; return whatever info was collected above
  869. return result if result else None
  870. def ftps_handshake_cooloff_deadline(ip_address: str) -> float | None:
  871. """The monotonic deadline of this printer's handshake cool-off, or None.
  872. Compare it across an operation to tell "a handshake failed while I was
  873. working" from "this printer was already cooling off from something else".
  874. Those need different words to the user, and the flag's presence alone
  875. cannot separate them: a caller that opted out of the cool-off can be
  876. running while an unrelated background fetch has one armed (#2898).
  877. """
  878. return BambuFTPClient._handshake_blocked_until.get(ip_address)
  879. def ftps_handshake_blocked(ip_address: str) -> bool:
  880. """True while this printer's FTPS handshake cool-off is still running.
  881. Callers that walk a list of candidate paths use this to give up on the
  882. remaining candidates: the failure is at the transport, below any path, so
  883. every one of them would fail identically (#2780).
  884. """
  885. return BambuFTPClient.handshake_blocked(ip_address)
  886. # Shared 3MF download cache (#972).
  887. #
  888. # Both the cover thumbnail endpoint (api/routes/printers.py) and the archive
  889. # metadata flow (main.py) fetch the same 3MF file over FTP during a print.
  890. # On slow / contended links (A1 Wi-Fi, large files) the duplicate transfers
  891. # compete for the printer's single FTP socket and trigger 425 "can't open
  892. # data channel" errors, feeding back into cause-2's retry storm.
  893. #
  894. # This cache stores the local path of a successfully-downloaded 3MF keyed
  895. # by (printer_id, normalized_name). Whichever flow downloads first populates
  896. # the cache; the other flow reuses the file read-only. Evicted on print
  897. # completion so a later print with the same name re-downloads fresh bytes.
  898. _threemf_path_cache: dict[tuple[int, str], Path] = {}
  899. def normalize_3mf_name(name: str) -> str:
  900. """Collapse various 3MF filename variants to a cache key.
  901. Bambu tooling produces names as bare subtask ("Part"), with .3mf, with
  902. .gcode.3mf, or (Studio-normalized) with spaces → underscores. All of
  903. these refer to the same print job on the same printer, so they must
  904. hash to the same cache key.
  905. """
  906. # Lowercase first so .3MF / .GCODE.3MF variants strip cleanly — a
  907. # real-world case since Windows-side tooling sometimes uppercases
  908. # extensions.
  909. cleaned = name.strip().lower().replace(".gcode.3mf", "").replace(".gcode", "").replace(".3mf", "")
  910. return cleaned.replace(" ", "_")
  911. def cache_3mf_download(printer_id: int, name: str, local_path: Path) -> None:
  912. """Record a successfully-downloaded 3MF so a sibling flow can reuse it."""
  913. _threemf_path_cache[(printer_id, normalize_3mf_name(name))] = local_path
  914. def get_cached_3mf(printer_id: int, name: str) -> Path | None:
  915. """Return a cached 3MF path for this printer/name if the file still exists."""
  916. key = (printer_id, normalize_3mf_name(name))
  917. cached = _threemf_path_cache.get(key)
  918. if cached and cached.exists() and cached.stat().st_size > 0:
  919. return cached
  920. # Evict dead entry — the file was cleaned up (temp dir clean, manual
  921. # deletion, restart) so the cache value is no longer usable.
  922. if cached:
  923. _threemf_path_cache.pop(key, None)
  924. return None
  925. def clear_3mf_cache(printer_id: int | None = None, delete_files: bool = True) -> None:
  926. """Drop cache entries for one printer (or all with None).
  927. When ``delete_files`` is True (default) the on-disk 3MF is removed as well
  928. — called from on_print_complete so temp files don't accumulate across
  929. prints. Tests that want to inspect the cache contents disable this.
  930. Only paths inside ``archive_dir/temp`` are unlinked. The dispatch sites
  931. added in #1166 also cache the live archive copy and library file bytes
  932. so /cover can skip FTP — those are *user data*, never the cache's to
  933. delete. Pre-fix this branch silently removed archive 3mfs on every print
  934. completion (#1212 + private reports of "file disappeared overnight").
  935. """
  936. from backend.app.core.config import settings as _config_settings
  937. temp_root = _config_settings.archive_dir / "temp"
  938. def _is_temp_path(path: Path) -> bool:
  939. try:
  940. return path.is_relative_to(temp_root)
  941. except (OSError, ValueError):
  942. return False
  943. def _maybe_unlink(path: Path) -> None:
  944. if not delete_files or not path.exists():
  945. return
  946. if not _is_temp_path(path):
  947. return
  948. try:
  949. path.unlink()
  950. except OSError as exc:
  951. logger.debug("3MF cache cleanup skipped %s: %s", path, exc)
  952. if printer_id is None:
  953. for path in list(_threemf_path_cache.values()):
  954. _maybe_unlink(path)
  955. _threemf_path_cache.clear()
  956. return
  957. for key in [k for k in _threemf_path_cache if k[0] == printer_id]:
  958. _maybe_unlink(_threemf_path_cache[key])
  959. _threemf_path_cache.pop(key, None)
  960. async def download_file_async(
  961. ip_address: str,
  962. access_code: str,
  963. remote_path: str,
  964. local_path: Path,
  965. timeout: float = 60.0,
  966. socket_timeout: float | None = None,
  967. printer_model: str | None = None,
  968. ) -> bool:
  969. """Async wrapper for downloading a file with timeout.
  970. For A1/A1 Mini printers, automatically tries prot_p first, then falls back
  971. to prot_c if the download fails. The working mode is cached for future operations.
  972. Args:
  973. ip_address: Printer IP address
  974. access_code: Printer access code
  975. remote_path: Remote file path on printer
  976. local_path: Local path to save file
  977. timeout: Overall operation timeout (asyncio)
  978. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  979. printer_model: Printer model for A1-specific workarounds
  980. """
  981. loop = asyncio.get_event_loop()
  982. is_a1 = printer_model in BambuFTPClient.A1_MODELS if printer_model else False
  983. # Per-attempt completion state: asyncio.wait_for cannot cancel
  984. # run_in_executor threads, so on timeout the executor may still complete
  985. # the download after we stop waiting. The thread flips `success` to True
  986. # ONLY after the file is fully written — a post-timeout check lets us
  987. # salvage the download without mistaking an in-progress partial write
  988. # for a completed one. Each attempt gets its own dict and event so a
  989. # zombie from an earlier attempt can't flip the flag for a later one.
  990. # The event is set in `_download`'s finally block so the post-timeout
  991. # path can wait for genuine thread completion instead of a fixed sleep.
  992. def _download(force_prot_c: bool, completion: dict, done: threading.Event) -> bool:
  993. mode_str = "prot_c" if force_prot_c else "prot_p"
  994. try:
  995. client = BambuFTPClient(
  996. ip_address,
  997. access_code,
  998. timeout=socket_timeout,
  999. printer_model=printer_model,
  1000. force_prot_c=force_prot_c,
  1001. )
  1002. if client.connect():
  1003. try:
  1004. result = client.download_to_file(remote_path, local_path)
  1005. if result:
  1006. BambuFTPClient.cache_mode(ip_address, mode_str)
  1007. completion["success"] = True
  1008. return result
  1009. finally:
  1010. client.disconnect()
  1011. return False
  1012. finally:
  1013. done.set()
  1014. async def _run(force_prot_c: bool) -> bool:
  1015. completion = {"success": False}
  1016. done = threading.Event()
  1017. try:
  1018. return await asyncio.wait_for(
  1019. loop.run_in_executor(_ftp_executor, _download, force_prot_c, completion, done), timeout=timeout
  1020. )
  1021. except TimeoutError:
  1022. # Slow WiFi links commonly overshoot ftp_timeout by 10–30 s without
  1023. # actually being stuck, so starting attempt 2 now would just contend
  1024. # with the still-progressing RETR on attempt 1 and produce the
  1025. # zombie-write race reported in #1014 (file landed on disk minutes
  1026. # after the retry loop had already given up). Wait for the worker
  1027. # thread to genuinely finish — capped at 30 s so a truly stuck
  1028. # connection can't stall a whole attempt indefinitely, with a 0.5 s
  1029. # floor so artificially small test timeouts still give zombies a
  1030. # realistic window to finish.
  1031. grace = max(min(timeout, 30.0), 0.5)
  1032. # Deliberately the DEFAULT executor, not `_ftp_executor`: this thread
  1033. # blocks waiting on `_download`, which is itself an `_ftp_executor`
  1034. # worker. Parking waiters in the same bounded pool as the workers they
  1035. # wait for is how you build a deadlock — with enough concurrent
  1036. # timeouts the waiters would occupy every slot and the downloads they
  1037. # are waiting for could never be scheduled.
  1038. await loop.run_in_executor(None, done.wait, grace)
  1039. if completion["success"] and local_path.exists() and local_path.stat().st_size > 0:
  1040. logger.info(
  1041. "FTP download wait_for timed out after %ss for %s, but thread completed within %ss grace (%s bytes) — salvaging",
  1042. timeout,
  1043. remote_path,
  1044. grace,
  1045. local_path.stat().st_size,
  1046. )
  1047. return True
  1048. logger.warning(
  1049. "FTP download timed out after %ss (plus %ss grace) for %s",
  1050. timeout,
  1051. grace,
  1052. remote_path,
  1053. )
  1054. return False
  1055. # Check if we have a cached mode for this printer
  1056. cached_mode = BambuFTPClient._mode_cache.get(ip_address)
  1057. if cached_mode:
  1058. force_prot_c = cached_mode == "prot_c"
  1059. return await _run(force_prot_c)
  1060. # No cached mode - try prot_p first
  1061. if await _run(False):
  1062. return True
  1063. # Download failed - for A1 models, try prot_c fallback
  1064. if is_a1:
  1065. logger.info("FTP download failed with prot_p for A1 model, trying prot_c fallback...")
  1066. return await _run(True)
  1067. return False
  1068. async def download_file_try_paths_async(
  1069. ip_address: str,
  1070. access_code: str,
  1071. remote_paths: list[str],
  1072. local_path: Path,
  1073. socket_timeout: float | None = None,
  1074. printer_model: str | None = None,
  1075. timeout: float = 90.0,
  1076. ) -> str | None:
  1077. """Try downloading a file from multiple paths using a single connection.
  1078. Returns the path that served the file, or ``None``. The path rather than a
  1079. bare flag because the caller usually cannot tell afterwards which candidate
  1080. hit, and on a printer that keeps uploads around for weeks that is the
  1081. difference between a diagnosable stale-copy match and an invisible one
  1082. (#1820). Callers testing it for truth are unaffected: a served path is
  1083. always a non-empty string.
  1084. Args:
  1085. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  1086. printer_model: Printer model for A1-specific workarounds
  1087. timeout: overall async cap. The per-socket timeout only bounds an
  1088. in-flight worker; it does NOT bound how long this coroutine waits
  1089. for a free slot in the fixed-size ``_ftp_executor``. On a large
  1090. farm where offline printers keep every worker busy on dead
  1091. connects, that queue wait is otherwise unbounded — and any caller
  1092. holding a DB connection while awaiting this would pin it until the
  1093. pool is exhausted (#2572). The cap converts that into a bounded
  1094. wait; the orphaned worker finishes and its result is discarded.
  1095. """
  1096. loop = asyncio.get_event_loop()
  1097. def _download():
  1098. client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
  1099. if not client.connect():
  1100. return None
  1101. try:
  1102. # FileNotOnPrinterError signals "try the next path", not "give up" —
  1103. # this function's whole purpose is to walk a list of candidates
  1104. # over one connection. Only a real transport error should bubble.
  1105. for remote_path in remote_paths:
  1106. try:
  1107. if client.download_to_file(remote_path, local_path):
  1108. return remote_path
  1109. except FileNotOnPrinterError:
  1110. continue
  1111. return None
  1112. finally:
  1113. client.disconnect()
  1114. try:
  1115. return await asyncio.wait_for(loop.run_in_executor(_ftp_executor, _download), timeout=timeout)
  1116. except TimeoutError:
  1117. logger.warning("FTP download_try_paths exceeded its %ss cap for %s (#2572)", timeout, ip_address)
  1118. return None
  1119. def _upload_deadline(local_path: Path) -> float:
  1120. """Derive an upload deadline from the file size (#2529).
  1121. See ``_UPLOAD_FLOOR_BYTES_PER_SEC``. An unstat-able file falls back to the
  1122. floor timeout — ``upload_file`` will fail on the open() anyway.
  1123. """
  1124. try:
  1125. size = local_path.stat().st_size
  1126. except OSError:
  1127. return _UPLOAD_MIN_TIMEOUT
  1128. return max(_UPLOAD_MIN_TIMEOUT, size / _UPLOAD_FLOOR_BYTES_PER_SEC)
  1129. # One upload at a time per printer. Two concurrent STOR commands for the same
  1130. # remote path leave a corrupt file on the SD card, and the printer reads as
  1131. # flaky rather than busy (#2529). Held for the duration of a transfer, so a
  1132. # second dispatch to the same printer queues behind the first instead of racing
  1133. # it. Keyed per event loop: an asyncio.Lock binds to the loop that first awaits
  1134. # it, and the test suite runs each case on a fresh loop.
  1135. _upload_locks: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[str, asyncio.Lock]] = (
  1136. weakref.WeakKeyDictionary()
  1137. )
  1138. def _upload_lock(loop: asyncio.AbstractEventLoop, ip_address: str) -> asyncio.Lock:
  1139. per_loop = _upload_locks.setdefault(loop, {})
  1140. lock = per_loop.get(ip_address)
  1141. if lock is None:
  1142. lock = asyncio.Lock()
  1143. per_loop[ip_address] = lock
  1144. return lock
  1145. async def upload_file_async(
  1146. ip_address: str,
  1147. access_code: str,
  1148. local_path: Path,
  1149. remote_path: str,
  1150. timeout: float | None = None,
  1151. progress_callback: Callable[[int, int], None] | None = None,
  1152. socket_timeout: float | None = None,
  1153. printer_model: str | None = None,
  1154. respect_handshake_cooloff: bool = True,
  1155. ) -> bool:
  1156. """Async wrapper for uploading a file with timeout and progress callback.
  1157. For A1/A1 Mini printers, automatically tries prot_p first, then falls back
  1158. to prot_c if the upload fails. The working mode is cached for future uploads.
  1159. Args:
  1160. ip_address: Printer IP address
  1161. access_code: Printer access code
  1162. local_path: Local file path to upload
  1163. remote_path: Remote path on printer
  1164. timeout: Overall deadline. ``None`` (the default) derives it from the
  1165. file size — see ``_upload_deadline``. A caller that passes a number
  1166. gets exactly that, which is what the tests rely on.
  1167. progress_callback: Optional callback for progress updates
  1168. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  1169. printer_model: Printer model for A1-specific workarounds
  1170. respect_handshake_cooloff: see ``BambuFTPClient.__init__``. False for a
  1171. user-initiated upload, whose attempts are bounded and were being
  1172. spent against a cool-off that outlives them (#2898).
  1173. """
  1174. loop = asyncio.get_event_loop()
  1175. is_a1 = printer_model in BambuFTPClient.A1_MODELS if printer_model else False
  1176. deadline = _upload_deadline(local_path) if timeout is None else timeout
  1177. # Set when the deadline expires. The worker checks it once per chunk.
  1178. cancel = threading.Event()
  1179. def _guarded_progress(uploaded: int, total: int) -> None:
  1180. if cancel.is_set():
  1181. raise UploadCancelled(f"upload of {remote_path} exceeded its {deadline:.0f}s deadline")
  1182. if progress_callback:
  1183. progress_callback(uploaded, total)
  1184. def _upload(force_prot_c: bool = False) -> bool:
  1185. mode_str = "prot_c" if force_prot_c else "prot_p"
  1186. logger.info(
  1187. f"FTP connecting to {ip_address} for upload (model={printer_model}, "
  1188. f"mode={mode_str}, socket_timeout={socket_timeout}s, deadline={deadline:.0f}s)..."
  1189. )
  1190. client = BambuFTPClient(
  1191. ip_address,
  1192. access_code,
  1193. timeout=socket_timeout,
  1194. printer_model=printer_model,
  1195. force_prot_c=force_prot_c,
  1196. respect_handshake_cooloff=respect_handshake_cooloff,
  1197. )
  1198. if client.connect():
  1199. logger.info("FTP connected to %s", ip_address)
  1200. try:
  1201. result = client.upload_file(local_path, remote_path, _guarded_progress)
  1202. if result:
  1203. # Cache the working mode
  1204. BambuFTPClient.cache_mode(ip_address, mode_str)
  1205. return result
  1206. finally:
  1207. client.disconnect()
  1208. logger.warning("FTP connection failed to %s", ip_address)
  1209. return False
  1210. async def _attempt(force_prot_c: bool) -> bool:
  1211. """Run one upload attempt, and make a timeout actually stop the transfer.
  1212. ``asyncio.wait_for`` cancels the *future*, never the executor thread
  1213. behind it. Before #2529 a slow-but-healthy upload that overran the
  1214. deadline left that thread streaming: it kept pushing bytes, kept firing
  1215. the progress callback, and the retry above put a *second* STOR of the
  1216. same file onto the same printer. The reporter's 96 MB job ran four
  1217. concurrent transfers and never landed. So on timeout we signal the
  1218. worker (it raises ``UploadCancelled`` from the progress callback, which
  1219. breaks the send loop and deletes the partial file) and wait for it to
  1220. actually go.
  1221. """
  1222. fut = loop.run_in_executor(_ftp_executor, lambda: _upload(force_prot_c))
  1223. try:
  1224. return await asyncio.wait_for(asyncio.shield(fut), timeout=deadline)
  1225. except TimeoutError:
  1226. cancel.set()
  1227. logger.warning(
  1228. "FTP upload of %s exceeded its %.0fs deadline — cancelling the transfer",
  1229. remote_path,
  1230. deadline,
  1231. )
  1232. try:
  1233. await asyncio.wait_for(asyncio.shield(fut), timeout=_UPLOAD_CANCEL_GRACE)
  1234. except UploadCancelled:
  1235. logger.info("FTP upload of %s cancelled; partial file removed from the printer", remote_path)
  1236. except TimeoutError:
  1237. # The thread is wedged somewhere that never reaches the callback
  1238. # (a blocked sendall, say). Nothing more we can do from here —
  1239. # but consume the eventual result so asyncio doesn't log the
  1240. # future's exception as unretrieved when it is garbage-collected.
  1241. logger.error(
  1242. "FTP upload thread for %s did not stop within %.0fs of the cancel signal",
  1243. remote_path,
  1244. _UPLOAD_CANCEL_GRACE,
  1245. )
  1246. fut.add_done_callback(_swallow_future_result)
  1247. except Exception as e:
  1248. logger.warning("FTP upload of %s errored while cancelling: %s", remote_path, e)
  1249. # Raise rather than return False: a deadline expiry means the link
  1250. # sustained less than the floor rate for the whole transfer, and a
  1251. # retry would only spend another full deadline finding that out
  1252. # again — with check_queue serialized, four of those block the
  1253. # entire print queue for hours. ``with_ftp_retry`` never retries it.
  1254. raise UploadCancelled(
  1255. f"Upload of {remote_path} to {ip_address} exceeded its {deadline:.0f}s deadline "
  1256. f"(link sustained less than {_UPLOAD_FLOOR_BYTES_PER_SEC // 1024} KB/s)"
  1257. ) from None
  1258. async with _upload_lock(loop, ip_address):
  1259. # Check if we have a cached mode for this printer
  1260. cached_mode = BambuFTPClient._mode_cache.get(ip_address)
  1261. if cached_mode:
  1262. # Use cached mode
  1263. return await _attempt(cached_mode == "prot_c")
  1264. # No cached mode - try prot_p first
  1265. if await _attempt(False):
  1266. return True
  1267. # Upload failed - for A1 models, try prot_c fallback
  1268. if is_a1:
  1269. logger.info("FTP upload failed with prot_p for A1 model, trying prot_c fallback...")
  1270. return await _attempt(True)
  1271. return False
  1272. def _swallow_future_result(fut: asyncio.Future) -> None:
  1273. """Retrieve a future's exception so asyncio doesn't log it as unhandled."""
  1274. if not fut.cancelled():
  1275. fut.exception()
  1276. async def list_files_async(
  1277. ip_address: str,
  1278. access_code: str,
  1279. path: str = "/",
  1280. timeout: float = 30.0,
  1281. socket_timeout: float | None = None,
  1282. printer_model: str | None = None,
  1283. ) -> list[dict]:
  1284. """Async wrapper for listing files with timeout.
  1285. Args:
  1286. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  1287. printer_model: Printer model for A1-specific workarounds
  1288. """
  1289. loop = asyncio.get_event_loop()
  1290. def _list():
  1291. client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
  1292. if client.connect():
  1293. try:
  1294. return client.list_files(path)
  1295. finally:
  1296. client.disconnect()
  1297. return []
  1298. try:
  1299. return await asyncio.wait_for(loop.run_in_executor(_ftp_executor, _list), timeout=timeout)
  1300. except TimeoutError:
  1301. logger.warning("FTP list_files timed out after %ss for %s", timeout, path)
  1302. return []
  1303. async def find_remote_file_async(
  1304. ip_address: str,
  1305. access_code: str,
  1306. remote_paths: list[str],
  1307. timeout: float = 30.0,
  1308. socket_timeout: float | None = None,
  1309. printer_model: str | None = None,
  1310. ) -> str | None:
  1311. """First of *remote_paths* the printer actually has, or None.
  1312. Answers "is this file there?" without fetching it, over a single
  1313. connection: one listing per distinct directory, reused across the
  1314. candidates that share it, and stops at the first hit. Written for the
  1315. connection diagnostic (#2856), which needs the answer for a file that can
  1316. be tens of megabytes and has no use for its contents.
  1317. Listing rather than ``SIZE``: LIST is what every Bambu firmware here is
  1318. known to answer, and a ``SIZE`` the server simply does not implement would
  1319. read as "the file is missing".
  1320. """
  1321. loop = asyncio.get_event_loop()
  1322. def _find() -> str | None:
  1323. client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
  1324. if not client.connect():
  1325. return None
  1326. try:
  1327. listed: dict[str, set[str]] = {}
  1328. for remote_path in remote_paths:
  1329. directory, _, name = remote_path.rpartition("/")
  1330. directory = directory or "/"
  1331. if directory not in listed:
  1332. listed[directory] = {
  1333. entry.get("name") for entry in client.list_files(directory) if not entry.get("is_directory")
  1334. }
  1335. if name in listed[directory]:
  1336. return remote_path
  1337. return None
  1338. finally:
  1339. client.disconnect()
  1340. try:
  1341. return await asyncio.wait_for(loop.run_in_executor(_ftp_executor, _find), timeout=timeout)
  1342. except TimeoutError:
  1343. logger.warning("FTP find_remote_file timed out after %ss on %s", timeout, ip_address)
  1344. return None
  1345. async def delete_file_async(
  1346. ip_address: str,
  1347. access_code: str,
  1348. remote_path: str,
  1349. socket_timeout: float | None = None,
  1350. printer_model: str | None = None,
  1351. timeout: float = 60.0,
  1352. respect_handshake_cooloff: bool = True,
  1353. ) -> DeleteResult:
  1354. """Async wrapper for deleting a file.
  1355. Returns :class:`DeleteResult` so callers can distinguish ``NOT_FOUND``
  1356. (550 — file isn't on the printer, no retry value) from ``FAILED``
  1357. (network / auth / transient — worth retrying or surfacing).
  1358. Args:
  1359. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  1360. printer_model: Printer model for A1-specific workarounds
  1361. timeout: overall async cap so a saturated ``_ftp_executor`` can't pin
  1362. the caller (and any DB connection it holds) indefinitely (#2572).
  1363. respect_handshake_cooloff: see ``BambuFTPClient.__init__``. The delete
  1364. that clears the way for a dispatch shares the upload's exemption --
  1365. it is one connection, and in #2898's trace it is the one that armed
  1366. the cool-off the upload then spent all four attempts against.
  1367. """
  1368. loop = asyncio.get_event_loop()
  1369. def _delete() -> DeleteResult:
  1370. client = BambuFTPClient(
  1371. ip_address,
  1372. access_code,
  1373. timeout=socket_timeout,
  1374. printer_model=printer_model,
  1375. respect_handshake_cooloff=respect_handshake_cooloff,
  1376. )
  1377. if client.connect():
  1378. try:
  1379. return client.delete_file(remote_path)
  1380. finally:
  1381. client.disconnect()
  1382. return DeleteResult.FAILED
  1383. try:
  1384. return await asyncio.wait_for(loop.run_in_executor(_ftp_executor, _delete), timeout=timeout)
  1385. except TimeoutError:
  1386. logger.warning("FTP delete_file exceeded its %ss cap for %s (#2572)", timeout, ip_address)
  1387. return DeleteResult.FAILED
  1388. async def download_file_bytes_async(
  1389. ip_address: str,
  1390. access_code: str,
  1391. remote_path: str,
  1392. socket_timeout: float | None = None,
  1393. printer_model: str | None = None,
  1394. timeout: float = 300.0,
  1395. expected_size: int | None = None,
  1396. ) -> bytes | None:
  1397. """Async wrapper for downloading file as bytes.
  1398. Args:
  1399. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  1400. printer_model: Printer model for A1-specific workarounds
  1401. timeout: overall async cap so a saturated ``_ftp_executor`` can't pin
  1402. the caller (and any DB connection it holds) indefinitely (#2572).
  1403. Generous by default because this pulls whole files (timelapse
  1404. video, gcode) which can legitimately take minutes over slow Wi-Fi —
  1405. the cap only guards against a permanently-starved pool, not a
  1406. slow-but-progressing transfer.
  1407. expected_size: size from the directory listing; a mismatch fails the
  1408. download instead of returning a truncated file. See
  1409. :meth:`BambuFTPClient.download_file`.
  1410. """
  1411. loop = asyncio.get_event_loop()
  1412. def _download():
  1413. client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
  1414. if client.connect():
  1415. try:
  1416. return client.download_file(remote_path, expected_size=expected_size)
  1417. finally:
  1418. client.disconnect()
  1419. return None
  1420. try:
  1421. return await asyncio.wait_for(loop.run_in_executor(_ftp_executor, _download), timeout=timeout)
  1422. except TimeoutError:
  1423. logger.warning("FTP download_bytes exceeded its %ss cap for %s (#2572)", timeout, ip_address)
  1424. return None
  1425. async def remote_file_settled(
  1426. ip_address: str,
  1427. access_code: str,
  1428. remote_path: str,
  1429. downloaded_bytes: int,
  1430. *,
  1431. printer_model: str | None = None,
  1432. ) -> bool:
  1433. """Confirm the printer has finished writing the file we just downloaded.
  1434. Matching the download against the size from the directory listing proves we
  1435. received what the listing *said*, not that the file was *finished*. The
  1436. timelapse scan's first look happens seconds after the print ends, which is
  1437. exactly when the printer is writing the video — so a file still growing can
  1438. be listed at a partial size, served at that size, and pass the length check
  1439. as a complete video (#2704).
  1440. That was survivable while the printer kept its copy. It isn't now that a
  1441. successful attach deletes the source, so re-list afterwards: if the file has
  1442. grown, what we hold is a prefix and the caller should discard it and try
  1443. again on the next round.
  1444. Returns True when the remote file can no longer differ from what we hold —
  1445. the size still matches, or the file is gone from the listing entirely and
  1446. so cannot grow any further. Returns False when it has changed size, and on
  1447. a listing failure, because "we could not check" must not read as "safe to
  1448. delete".
  1449. """
  1450. directory, _, name = remote_path.rpartition("/")
  1451. files = await list_files_async(ip_address, access_code, directory or "/", printer_model=printer_model)
  1452. if not files:
  1453. logger.warning("[TIMELAPSE] Could not re-list %s to confirm %s is complete", directory or "/", name)
  1454. return False
  1455. for f in files:
  1456. if f.get("name") == name:
  1457. size = f.get("size")
  1458. if size == downloaded_bytes:
  1459. return True
  1460. logger.info(
  1461. "[TIMELAPSE] %s is still being written (%s bytes now, %s when downloaded) — will retry",
  1462. name,
  1463. size,
  1464. downloaded_bytes,
  1465. )
  1466. return False
  1467. # Vanished between the download and now. Nothing left that could grow, and
  1468. # nothing left to delete either.
  1469. logger.debug("[TIMELAPSE] %s is no longer on the printer after download", name)
  1470. return True
  1471. async def delete_archived_timelapse(
  1472. ip_address: str,
  1473. access_code: str,
  1474. remote_path: str,
  1475. *,
  1476. verified: bool,
  1477. printer_model: str | None = None,
  1478. printer_name: str = "",
  1479. ) -> bool:
  1480. """Remove a timelapse from the printer once it is safely in the archive.
  1481. Call this only after the attach succeeded (#2704). Keeping ``/timelapse``
  1482. down to just the unclaimed videos is what makes the snapshot diff
  1483. unambiguous rather than merely usually-right, and it stops P1S cards
  1484. filling with AVIs.
  1485. ``verified`` must say whether the downloaded byte count was checked against
  1486. the size the directory listing reported. It is required rather than
  1487. defaulted because this is the one irreversible step in the flow: an FTPS
  1488. data connection that closes early does not always raise, so an unverified
  1489. transfer can be a partial file that looks complete, and deleting the source
  1490. would then destroy the only good copy. The check lives here rather than at
  1491. each call site so no future caller can omit it.
  1492. Best-effort otherwise: a printer that refuses the delete keeps its copy, the
  1493. diff still excludes that filename next time because it is attached to an
  1494. archive, and nothing else in the flow cares. Returns True only on an actual
  1495. delete or a 550 (already gone).
  1496. """
  1497. if not verified:
  1498. logger.warning(
  1499. "[TIMELAPSE] Not deleting %s from printer %s: the download was never size-checked",
  1500. remote_path,
  1501. printer_name,
  1502. )
  1503. return False
  1504. for attempt in range(1, 4):
  1505. try:
  1506. result = await delete_file_async(ip_address, access_code, remote_path, printer_model=printer_model)
  1507. except Exception as e:
  1508. result = DeleteResult.FAILED
  1509. logger.warning("[TIMELAPSE] Delete attempt %d/3 raised for %s: %s", attempt, remote_path, e)
  1510. if result == DeleteResult.DELETED:
  1511. logger.info("[TIMELAPSE] Deleted %s from printer %s after archiving", remote_path, printer_name)
  1512. return True
  1513. if result == DeleteResult.NOT_FOUND:
  1514. # 550 never recovers by waiting — the printer already cleaned up.
  1515. logger.debug("[TIMELAPSE] %s already gone from printer %s", remote_path, printer_name)
  1516. return True
  1517. if attempt < 3:
  1518. await asyncio.sleep(2)
  1519. logger.warning(
  1520. "[TIMELAPSE] Could not delete %s from printer %s (it stays on the card; the archive copy is unaffected)",
  1521. remote_path,
  1522. printer_name,
  1523. )
  1524. return False
  1525. async def get_storage_info_async(
  1526. ip_address: str,
  1527. access_code: str,
  1528. socket_timeout: float | None = None,
  1529. printer_model: str | None = None,
  1530. timeout: float = 60.0,
  1531. ) -> dict | None:
  1532. """Async wrapper for getting storage info.
  1533. Args:
  1534. socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
  1535. printer_model: Printer model for A1-specific workarounds
  1536. timeout: overall async cap so a saturated ``_ftp_executor`` can't pin
  1537. the caller (and any DB connection it holds) indefinitely (#2572).
  1538. """
  1539. loop = asyncio.get_event_loop()
  1540. def _get_storage():
  1541. client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
  1542. if client.connect():
  1543. try:
  1544. return client.get_storage_info()
  1545. finally:
  1546. client.disconnect()
  1547. return None
  1548. try:
  1549. return await asyncio.wait_for(loop.run_in_executor(_ftp_executor, _get_storage), timeout=timeout)
  1550. except TimeoutError:
  1551. logger.warning("FTP get_storage_info exceeded its %ss cap for %s (#2572)", timeout, ip_address)
  1552. return None
  1553. async def get_ftp_retry_settings() -> tuple[bool, int, float, float]:
  1554. """Get FTP retry settings from database.
  1555. Returns:
  1556. Tuple of (retry_enabled, retry_count, retry_delay, timeout)
  1557. """
  1558. from backend.app.api.routes.settings import get_setting
  1559. from backend.app.core.database import async_session
  1560. async with async_session() as db:
  1561. enabled = (await get_setting(db, "ftp_retry_enabled") or "true") == "true"
  1562. count = int(await get_setting(db, "ftp_retry_count") or "3")
  1563. delay = float(await get_setting(db, "ftp_retry_delay") or "2")
  1564. timeout = float(await get_setting(db, "ftp_timeout") or "30")
  1565. return enabled, count, delay, timeout
  1566. async def with_ftp_retry(
  1567. operation: Callable[..., Awaitable[T]],
  1568. *args,
  1569. max_retries: int = 3,
  1570. retry_delay: float = 2.0,
  1571. operation_name: str = "FTP operation",
  1572. non_retry_exceptions: tuple[type[BaseException], ...] = (),
  1573. cooloff_ip: str | None = None,
  1574. **kwargs,
  1575. ) -> T | None:
  1576. """Execute FTP operation with retry logic.
  1577. Args:
  1578. operation: Async function to execute
  1579. *args: Positional arguments for the operation
  1580. max_retries: Number of retry attempts (default: 3)
  1581. retry_delay: Seconds to wait between retries (default: 2.0)
  1582. operation_name: Name for logging purposes
  1583. non_retry_exceptions: Exception types that should immediately abort retries
  1584. cooloff_ip: printer IP whose FTPS handshake cool-off should end the loop
  1585. early. Pass it from any caller that respects the cool-off; leave it
  1586. unset for one that opted out, or the loop would stop on a gate its
  1587. own attempts are ignoring (#2898).
  1588. **kwargs: Keyword arguments for the operation
  1589. Returns:
  1590. Result of the operation, or None if all attempts fail
  1591. ``UploadCancelled`` is never retried, whatever the caller passes: it means
  1592. the transfer overran its size-derived deadline, so a retry would spend
  1593. another full deadline reaching the same conclusion (#2529).
  1594. """
  1595. last_error = None
  1596. attempts_made = 0
  1597. for attempt in range(max_retries + 1):
  1598. attempts_made = attempt + 1
  1599. try:
  1600. result = await operation(*args, **kwargs)
  1601. # Check for "falsy" success indicators
  1602. if result not in (False, None, []):
  1603. if attempt > 0:
  1604. logger.info("%s succeeded on attempt %s/%s", operation_name, attempt + 1, max_retries + 1)
  1605. return result
  1606. # Operation returned failure indicator
  1607. if attempt > 0:
  1608. logger.info("%s attempt %s/%s returned failure", operation_name, attempt + 1, max_retries + 1)
  1609. except UploadCancelled:
  1610. raise
  1611. except Exception as e:
  1612. if non_retry_exceptions and isinstance(e, non_retry_exceptions):
  1613. raise
  1614. last_error = e
  1615. logger.warning("%s attempt %s/%s failed: %s", operation_name, attempt + 1, max_retries + 1, e)
  1616. # Don't wait after the last attempt
  1617. if attempt < max_retries:
  1618. # A cool-off outlasts this loop by two orders of magnitude, so once
  1619. # it is armed every remaining attempt returns False without opening
  1620. # a socket. Spending them anyway bought nothing and cost the caller
  1621. # `max_retries * retry_delay` seconds of sleeping, then reported the
  1622. # failure with the wrong reason (#2898).
  1623. if cooloff_ip and ftps_handshake_blocked(cooloff_ip):
  1624. logger.warning(
  1625. "%s: stopping after attempt %s/%s — %s is inside its FTPS handshake cool-off, "
  1626. "so the remaining attempts would not reach it",
  1627. operation_name,
  1628. attempt + 1,
  1629. max_retries + 1,
  1630. cooloff_ip,
  1631. )
  1632. break
  1633. logger.info("%s will retry in %ss...", operation_name, retry_delay)
  1634. await asyncio.sleep(retry_delay)
  1635. # attempts_made, not max_retries + 1: the loop can stop early on a cool-off,
  1636. # and reporting attempts that were never made is how #2898 read as a network
  1637. # problem when nothing had gone near the network.
  1638. logger.error("%s failed after %s attempts", operation_name, attempts_made)
  1639. if last_error:
  1640. logger.debug("Last error: %s", last_error)
  1641. return None