print_storage.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. """Can FTPS see the file this print is running from? (#2780)
  2. Bambuddy reads a print's 3MF, cover and timelapse off the printer over implicit
  3. FTPS on port 990. On every Bambu model that port serves **external storage only**
  4. -- the SD card or USB stick. It is not a view of the printer's filesystem.
  5. H2-series and P2S firmware default to keeping the sliced file on internal eMMC
  6. instead, and BambuStudio uploads there over a separate service on port 6000
  7. (the "BambuTunnelLocal" protocol -- see #2762, which tracks implementing it).
  8. The dispatch says where it went: the ``project_file`` command carries ``url``,
  9. which is ``ftp://<name>`` for external storage and ``brtc://emmc/<name>`` for
  10. internal.
  11. Before this module we ignored ``url`` and swept anyway: six filename variants
  12. across five directories with up to four retries for the 3MF, then sixteen more
  13. paths for the cover, then the timelapse scan -- roughly 110 FTPS connections per
  14. print, every one of them certain to 550. The user-visible result was an archive
  15. card with nothing on it and no stated reason, which read as a Bambuddy bug and
  16. was reported as one four times (#1170, #2524, #2762, #2780).
  17. But that URL is not the last word on reachability, and reading it as one was
  18. itself a regression (#2856). It says where the printer *chose* to put the file,
  19. not whether port 990 can serve it -- measured on an H2D (firmware 01.03.00.00,
  20. card in the slot): every ``brtc://emmc/<name>`` print of that reporter's was
  21. sitting under ``/cache/<name>`` and downloaded fine, 19 MB included, until this
  22. module started skipping the lookup. On #2780's P2S and H2C the same URL really
  23. did mean nothing was there. So an internal-storage URL earns a *bounded probe*
  24. rather than a skip: it names the exact file, which turns the 110-connection
  25. sweep into one connection walking five paths, and the answer comes from the
  26. printer instead of from a guess about its model. Only when that probe misses
  27. does the verdict's ``reason`` stand -- see :func:`probe_filename_from_url` and
  28. :func:`ftp_probe_paths`, and the callers that run it.
  29. The rule here is deliberately one-sided: **skip only on positive evidence**.
  30. Silence is not evidence -- a printer that never publishes ``sdcard`` and never
  31. had a ``project_file`` pass through the request topic (some brokers refuse the
  32. subscription) must keep the old behaviour exactly, or this becomes a regression
  33. for installs whose archives work fine today.
  34. """
  35. from __future__ import annotations
  36. from dataclasses import dataclass
  37. # The scheme that means "uploaded to external storage, reachable over FTPS".
  38. # An unknown scheme -- whatever Bambu ships next -- must not read as fine, so
  39. # this matches the reachable value rather than the unreachable one.
  40. _EXTERNAL_STORAGE_SCHEME = "ftp"
  41. # ``file://`` means the file was already on the printer when the print started:
  42. # a reprint from the touchscreen, from Handy, or a Studio send-to-storage
  43. # followed by a print. The path says which storage, and only the printer's own
  44. # internal roots are out of reach of port 990. Measured on an H2D, 2026-08-17:
  45. # ``file:///media/usb0/foobar.gcode.3mf`` while that exact file was listable and
  46. # downloadable over FTPS.
  47. _LOCAL_FILE_SCHEME = "file"
  48. # Internal roots seen in ``file://`` paths. ``/userdata`` is where the model
  49. # cache lives (``/userdata/model/history/<name>``, confirmed via the printer's
  50. # own file listing), and port 990 does not serve it.
  51. _INTERNAL_FILE_PREFIXES = ("/userdata/",)
  52. # Reason slugs. These cross the API into the UI and into the connection
  53. # diagnostic, so they are part of the contract: the frontend maps each to its
  54. # own explanation and its own advice. Keep them stable.
  55. REASON_INTERNAL_STORAGE = "internal_storage"
  56. REASON_NO_EXTERNAL_STORAGE = "no_external_storage"
  57. # Same verdict as REASON_INTERNAL_STORAGE, different cause -- and the cause is
  58. # the whole of the advice. `brtc://emmc/<name>` is a *dispatch* that chose
  59. # internal storage: a slicer sent the file and the printer filed it where port
  60. # 990 cannot serve it, which the operator can change by sending it elsewhere.
  61. # `file:///userdata/...` is a print of a file that was already on the printer --
  62. # a touchscreen re-print, a Handy start, a Studio send-to-storage printed later
  63. # -- so there was no dispatch to aim anywhere, and telling that operator to pick
  64. # "External" in Send describes a step they never took (#1820).
  65. REASON_INTERNAL_HISTORY = "internal_history"
  66. # Not a storage verdict — the file's location was never in question. The
  67. # printer's FTPS service was inside its post-failed-handshake cool-off when the
  68. # print started, so the sweep was skipped without a single connection. Stamped
  69. # on the fallback archive by the print-start handler rather than returned by
  70. # `_verdict`, and unlike the two above it is temporary: it is the one reason a
  71. # retry is worth scheduling (#2957).
  72. REASON_FTPS_COOLOFF = "ftps_cooloff"
  73. # Where a sliced file has ever been found over FTPS, in the order the sweep in
  74. # `main.py` tries them -- root first, which is where A1/P1-series uploads land
  75. # (#972), then `/cache`, which is where the H2D keeps its copy of an eMMC job
  76. # (#2856).
  77. _PROBE_DIRECTORIES = ("/", "/cache/", "/model/", "/data/", "/data/Metadata/")
  78. # Longest name worth probing for. Every filesystem the printer could be serving
  79. # from caps a name at 255 bytes, so anything past this cannot be a file that is
  80. # actually there -- and it would be written to a local temp path too.
  81. _MAX_PROBE_FILENAME_LENGTH = 255
  82. @dataclass(frozen=True)
  83. class StorageVerdict:
  84. """Whether an FTPS sweep for this print's file is worth running.
  85. ``reachable`` False always carries a ``reason``; True never does.
  86. ``probe_filename`` is the exact name the dispatch gave, present only on an
  87. unreachable verdict and only when the URL named a ``.3mf``. It is the
  88. caller's chance to check the claim cheaply before acting on ``reason`` --
  89. see :func:`ftp_probe_paths`.
  90. """
  91. reachable: bool
  92. reason: str | None = None
  93. probe_filename: str | None = None
  94. _REACHABLE = StorageVerdict(reachable=True)
  95. def url_is_external_storage(project_url: str | None) -> bool | None:
  96. """Does *project_url* name a file on external storage?
  97. ``None`` when there is no URL to read, which is not the same answer as
  98. False and must not be collapsed into one by callers.
  99. """
  100. # Type-checked, not just truth-checked: this value arrives straight off the
  101. # wire, so it is whatever the sender put there. Anything that is not a
  102. # string is not an answer.
  103. if not isinstance(project_url, str) or not project_url:
  104. return None
  105. scheme, separator, path = project_url.partition("://")
  106. if not separator:
  107. # No scheme at all. Real dispatches always carry one, so rather than
  108. # guess at a bare path, decline to answer and let the caller fall
  109. # through to its existing behaviour.
  110. return None
  111. scheme = scheme.lower()
  112. if scheme == _EXTERNAL_STORAGE_SCHEME:
  113. return True
  114. if scheme == _LOCAL_FILE_SCHEME:
  115. # Only a known-internal path is positive evidence of somewhere FTPS
  116. # cannot reach. Anything else is unknown, which sweeps -- this module
  117. # skips only on positive evidence, and a path we do not recognise is
  118. # not that. Returning False here instead is what made a print of a file
  119. # sitting on the stick report as internal storage and archive with no
  120. # 3MF, when the sweep would have found it immediately.
  121. if path.startswith(_INTERNAL_FILE_PREFIXES):
  122. return False
  123. return None
  124. return False
  125. def probe_filename_from_url(project_url: str | None) -> str | None:
  126. """The exact 3MF name *project_url* points at, for a bounded FTPS probe.
  127. ``brtc://emmc/Cube.gcode.3mf`` -> ``Cube.gcode.3mf``, and likewise for the
  128. internal ``file://`` paths. ``None`` when there is no name to probe with,
  129. which is the caller's signal to fall back to the sweep it would have run.
  130. Only ``.3mf`` names come back. A print running from a bare gcode has no 3MF
  131. to find at any path, so probing for one would spend connections to learn
  132. what the extension already said.
  133. The value arrives from the network -- whatever the slicer or the printer
  134. put in the dispatch -- and callers turn it into both a remote path and a
  135. local temp filename, so anything that could steer either is refused rather
  136. than sanitized: no separators, no traversal, no control characters.
  137. """
  138. if not isinstance(project_url, str):
  139. return None
  140. _scheme, separator, path = project_url.partition("://")
  141. if not separator:
  142. return None
  143. name = path.rpartition("/")[2].strip()
  144. if not name or len(name) > _MAX_PROBE_FILENAME_LENGTH:
  145. return None
  146. # A leading dot is either a traversal segment or a hidden file; neither is
  147. # a sliced upload, and both would put an odd path on the wire. A backslash
  148. # is a path separator on the host even though it is a legal character in
  149. # the printer's own filesystem, which is how a name could reach outside the
  150. # temp directory it is written to.
  151. if name.startswith(".") or "\\" in name:
  152. return None
  153. if any(character < " " or character == "\x7f" for character in name):
  154. return None
  155. if not name.lower().endswith(".3mf"):
  156. return None
  157. return name
  158. def ftp_probe_paths(filename: str) -> list[str]:
  159. """Remote paths to try for *filename*, best first.
  160. One filename across the known directories, because the dispatch already
  161. told us the name and only the directory is in question (#2856). Callers
  162. walk the list over a single connection, against the sweep's ~110.
  163. """
  164. return [f"{directory}{filename}" for directory in _PROBE_DIRECTORIES]
  165. def external_storage_present(state: object | None) -> bool:
  166. """Does the printer have external storage for FTPS to serve at all?
  167. Narrower than :func:`print_file_reachable_over_ftp` and deliberately so.
  168. The printer records its timelapse to the card itself, so *where the sliced
  169. file went* says nothing about whether a video exists -- an H2C that kept
  170. the 3MF on eMMC still writes ``/timelapse`` to an inserted card. Only the
  171. empty-slot case rules a scan out, and only when the printer said the slot
  172. is empty rather than never mentioning it.
  173. """
  174. if state is None:
  175. return True
  176. return not (getattr(state, "sdcard_reported", False) and not getattr(state, "sdcard", False))
  177. def print_file_reachable_over_ftp(state: object | None) -> StorageVerdict:
  178. """Decide whether to run an FTPS sweep for the print *state* is running.
  179. *state* is a ``PrinterState`` (duck-typed so tests and callers can pass a
  180. stand-in). Reads ``current_project_url``, ``sdcard`` and ``sdcard_reported``.
  181. Deliberately the *per-print* URL, not the sticky one: a print Bambuddy saw
  182. no dispatch for must read as unknown and sweep, rather than inherit the
  183. previous job's destination. Roughly a fifth of the print starts in #2780's
  184. bundle had no dispatch on the request topic -- touchscreen reprints and
  185. restart recovery -- and inheriting a stale internal-storage answer there
  186. would skip a sweep that could have found the file.
  187. Returns :data:`_REACHABLE` unless something positively says otherwise.
  188. """
  189. return _verdict(getattr(state, "current_project_url", None), state)
  190. def last_print_storage_verdict(state: object | None) -> StorageVerdict:
  191. """Same question, asked of the last dispatch seen whenever that was.
  192. For reporting only -- the connection diagnostic is normally run after the
  193. print that prompted it, by which point the per-print URL has been cleared.
  194. Never gate an FTPS sweep on this: it may describe a different print.
  195. """
  196. return _verdict(getattr(state, "last_project_url", None), state)
  197. def _internal_reason(project_url: str | None) -> str:
  198. """Which flavour of "internal" *project_url* names.
  199. Only ever reached on a negative verdict, so the URL is one of the two
  200. shapes :func:`url_is_external_storage` answers False for.
  201. """
  202. if not isinstance(project_url, str):
  203. return REASON_INTERNAL_STORAGE
  204. scheme = project_url.partition("://")[0].lower()
  205. return REASON_INTERNAL_HISTORY if scheme == _LOCAL_FILE_SCHEME else REASON_INTERNAL_STORAGE
  206. def _verdict(project_url: str | None, state: object | None) -> StorageVerdict:
  207. if state is None:
  208. return _REACHABLE
  209. # Strongest signal, and specific to the print in question: the dispatcher
  210. # named the destination.
  211. external = url_is_external_storage(project_url)
  212. if external is False:
  213. # Worth probing only if there is external storage for the probe to find
  214. # anything on. An empty slot answers the question the probe would ask,
  215. # and #2780's H2C sat that way for three weeks -- one connection per
  216. # print start is small, but it is not worth spending to be told what
  217. # the printer already said.
  218. return StorageVerdict(
  219. reachable=False,
  220. reason=_internal_reason(project_url),
  221. probe_filename=probe_filename_from_url(project_url) if external_storage_present(state) else None,
  222. )
  223. if external is True:
  224. # It said external storage, so sweep even if the card flags disagree.
  225. # Trusting the specific claim over the general one is what keeps a
  226. # printer that misreports `sdcard` from losing archives that work
  227. # today -- a false skip is a regression, a needless sweep is only slow.
  228. return _REACHABLE
  229. # Model-independent fallback for printers whose broker refuses the request
  230. # topic, so we never see a `project_file` at all. An empty slot means FTPS
  231. # has nothing to serve from any path -- but only when the printer actually
  232. # said so. `sdcard` defaults to False, and acting on that default would
  233. # skip the sweep for every printer that simply doesn't publish the field.
  234. if getattr(state, "sdcard_reported", False) and not getattr(state, "sdcard", False):
  235. return StorageVerdict(reachable=False, reason=REASON_NO_EXTERNAL_STORAGE)
  236. return _REACHABLE