print_storage.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  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. When that happens there is no file on FTPS to find, at any path, and no TLS
  9. option, retry or directory guess changes that. The dispatch says so plainly:
  10. the ``project_file`` command carries ``url``, which is ``ftp://<name>`` for
  11. external storage and ``brtc://emmc/<name>`` for internal.
  12. Before this module we ignored ``url`` and swept anyway: six filename variants
  13. across five directories with up to four retries for the 3MF, then sixteen more
  14. paths for the cover, then the timelapse scan -- roughly 110 FTPS connections per
  15. print, every one of them certain to 550. The user-visible result was an archive
  16. card with nothing on it and no stated reason, which read as a Bambuddy bug and
  17. was reported as one four times (#1170, #2524, #2762, #2780).
  18. The rule here is deliberately one-sided: **skip only on positive evidence**.
  19. Silence is not evidence -- a printer that never publishes ``sdcard`` and never
  20. had a ``project_file`` pass through the request topic (some brokers refuse the
  21. subscription) must keep the old behaviour exactly, or this becomes a regression
  22. for installs whose archives work fine today.
  23. """
  24. from __future__ import annotations
  25. from dataclasses import dataclass
  26. # The scheme that means "uploaded to external storage, reachable over FTPS".
  27. # An unknown scheme -- whatever Bambu ships next -- must not read as fine, so
  28. # this matches the reachable value rather than the unreachable one.
  29. _EXTERNAL_STORAGE_SCHEME = "ftp"
  30. # ``file://`` means the file was already on the printer when the print started:
  31. # a reprint from the touchscreen, from Handy, or a Studio send-to-storage
  32. # followed by a print. The path says which storage, and only the printer's own
  33. # internal roots are out of reach of port 990. Measured on an H2D, 2026-08-17:
  34. # ``file:///media/usb0/foobar.gcode.3mf`` while that exact file was listable and
  35. # downloadable over FTPS.
  36. _LOCAL_FILE_SCHEME = "file"
  37. # Internal roots seen in ``file://`` paths. ``/userdata`` is where the model
  38. # cache lives (``/userdata/model/history/<name>``, confirmed via the printer's
  39. # own file listing), and port 990 does not serve it.
  40. _INTERNAL_FILE_PREFIXES = ("/userdata/",)
  41. # Reason slugs. These cross the API into the UI and into the connection
  42. # diagnostic, so they are part of the contract: the frontend maps each to its
  43. # own explanation and its own advice. Keep them stable.
  44. REASON_INTERNAL_STORAGE = "internal_storage"
  45. REASON_NO_EXTERNAL_STORAGE = "no_external_storage"
  46. @dataclass(frozen=True)
  47. class StorageVerdict:
  48. """Whether an FTPS sweep for this print's file is worth running.
  49. ``reachable`` False always carries a ``reason``; True never does.
  50. """
  51. reachable: bool
  52. reason: str | None = None
  53. _REACHABLE = StorageVerdict(reachable=True)
  54. def url_is_external_storage(project_url: str | None) -> bool | None:
  55. """Does *project_url* name a file on external storage?
  56. ``None`` when there is no URL to read, which is not the same answer as
  57. False and must not be collapsed into one by callers.
  58. """
  59. # Type-checked, not just truth-checked: this value arrives straight off the
  60. # wire, so it is whatever the sender put there. Anything that is not a
  61. # string is not an answer.
  62. if not isinstance(project_url, str) or not project_url:
  63. return None
  64. scheme, separator, path = project_url.partition("://")
  65. if not separator:
  66. # No scheme at all. Real dispatches always carry one, so rather than
  67. # guess at a bare path, decline to answer and let the caller fall
  68. # through to its existing behaviour.
  69. return None
  70. scheme = scheme.lower()
  71. if scheme == _EXTERNAL_STORAGE_SCHEME:
  72. return True
  73. if scheme == _LOCAL_FILE_SCHEME:
  74. # Only a known-internal path is positive evidence of somewhere FTPS
  75. # cannot reach. Anything else is unknown, which sweeps -- this module
  76. # skips only on positive evidence, and a path we do not recognise is
  77. # not that. Returning False here instead is what made a print of a file
  78. # sitting on the stick report as internal storage and archive with no
  79. # 3MF, when the sweep would have found it immediately.
  80. if path.startswith(_INTERNAL_FILE_PREFIXES):
  81. return False
  82. return None
  83. return False
  84. def external_storage_present(state: object | None) -> bool:
  85. """Does the printer have external storage for FTPS to serve at all?
  86. Narrower than :func:`print_file_reachable_over_ftp` and deliberately so.
  87. The printer records its timelapse to the card itself, so *where the sliced
  88. file went* says nothing about whether a video exists -- an H2C that kept
  89. the 3MF on eMMC still writes ``/timelapse`` to an inserted card. Only the
  90. empty-slot case rules a scan out, and only when the printer said the slot
  91. is empty rather than never mentioning it.
  92. """
  93. if state is None:
  94. return True
  95. return not (getattr(state, "sdcard_reported", False) and not getattr(state, "sdcard", False))
  96. def print_file_reachable_over_ftp(state: object | None) -> StorageVerdict:
  97. """Decide whether to run an FTPS sweep for the print *state* is running.
  98. *state* is a ``PrinterState`` (duck-typed so tests and callers can pass a
  99. stand-in). Reads ``current_project_url``, ``sdcard`` and ``sdcard_reported``.
  100. Deliberately the *per-print* URL, not the sticky one: a print Bambuddy saw
  101. no dispatch for must read as unknown and sweep, rather than inherit the
  102. previous job's destination. Roughly a fifth of the print starts in #2780's
  103. bundle had no dispatch on the request topic -- touchscreen reprints and
  104. restart recovery -- and inheriting a stale internal-storage answer there
  105. would skip a sweep that could have found the file.
  106. Returns :data:`_REACHABLE` unless something positively says otherwise.
  107. """
  108. return _verdict(getattr(state, "current_project_url", None), state)
  109. def last_print_storage_verdict(state: object | None) -> StorageVerdict:
  110. """Same question, asked of the last dispatch seen whenever that was.
  111. For reporting only -- the connection diagnostic is normally run after the
  112. print that prompted it, by which point the per-print URL has been cleared.
  113. Never gate an FTPS sweep on this: it may describe a different print.
  114. """
  115. return _verdict(getattr(state, "last_project_url", None), state)
  116. def _verdict(project_url: str | None, state: object | None) -> StorageVerdict:
  117. if state is None:
  118. return _REACHABLE
  119. # Strongest signal, and specific to the print in question: the dispatcher
  120. # named the destination.
  121. external = url_is_external_storage(project_url)
  122. if external is False:
  123. return StorageVerdict(reachable=False, reason=REASON_INTERNAL_STORAGE)
  124. if external is True:
  125. # It said external storage, so sweep even if the card flags disagree.
  126. # Trusting the specific claim over the general one is what keeps a
  127. # printer that misreports `sdcard` from losing archives that work
  128. # today -- a false skip is a regression, a needless sweep is only slow.
  129. return _REACHABLE
  130. # Model-independent fallback for printers whose broker refuses the request
  131. # topic, so we never see a `project_file` at all. An empty slot means FTPS
  132. # has nothing to serve from any path -- but only when the printer actually
  133. # said so. `sdcard` defaults to False, and acting on that default would
  134. # skip the sweep for every printer that simply doesn't publish the field.
  135. if getattr(state, "sdcard_reported", False) and not getattr(state, "sdcard", False):
  136. return StorageVerdict(reachable=False, reason=REASON_NO_EXTERNAL_STORAGE)
  137. return _REACHABLE