ftp_profiles.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. """Per-printer-model FTP tuning knobs.
  2. Mirrors the shape of :mod:`backend.app.services.camera_profiles` — a
  3. small registry of per-model overrides so quirky firmwares can be
  4. tuned without sprinkling ``if model == "X":`` branches through
  5. ``bambu_ftp.py``. Adding a new model's quirk is a config edit (an
  6. entry in ``_PROFILES`` plus the alias for its internal SSDP code if
  7. needed), not another hard-coded branch.
  8. The default profile matches the historical pre-fix behaviour, so
  9. every model that doesn't have an entry here keeps its existing FTP
  10. behaviour byte-for-byte.
  11. Currently only the TLS-version cap lives here (P2S firmware
  12. 01.02.00.00 needs it — see ``cap_tls_v1_2`` below). The A1
  13. data-channel-plaintext quirk still lives in :class:`BambuFTPClient`
  14. via ``A1_MODELS`` / ``skip_session_reuse``; folding that into a
  15. profile field is a future cleanup, not load-bearing for this fix.
  16. """
  17. from __future__ import annotations
  18. from dataclasses import dataclass
  19. @dataclass(frozen=True)
  20. class FTPProfile:
  21. """Tuning knobs for one printer model's FTP path.
  22. All defaults reflect the historical behaviour. Models with quirky
  23. firmware override individual fields rather than re-defining the
  24. whole profile.
  25. """
  26. # Pin the SSL context's ``maximum_version`` to TLS 1.2.
  27. #
  28. # ``ssl.create_default_context()`` negotiates TLS 1.3 when both peers
  29. # support it. Some Bambu printer firmwares (P2S 01.02.00.00 confirmed
  30. # by @iitazz, #1401) implement session reuse on the FTPS data
  31. # channel against an old vsFTPd build that doesn't tolerate TLS
  32. # 1.3's asynchronous session-ticket model: the data channel gets
  33. # torn down mid-stream and the upload aborts with 426 "Failure
  34. # reading network stream" — visible as a clean truncation at a
  35. # chunk boundary (one reporter saw exactly 7 × 64 KB landed on
  36. # the printer). Capping to TLS 1.2 makes session resumption
  37. # synchronous and the upload completes normally.
  38. #
  39. # This cap only bites on models that *offer* 1.3 in the first place,
  40. # and on the evidence so far none of them do. Probed directly on
  41. # :990, an X1C and an H2D refuse TLS 1.0, 1.1 and 1.3 and complete
  42. # only on 1.2; @grolmus then probed a 9-printer farm (#2780,
  43. # 2026-08-21) and got the same result on six P2S units, two X1C and
  44. # an H2D — tls1_3 refused, tls1_2 ok, every one. This comment used
  45. # to claim "the P2S evidently does offer 1.3"; six say otherwise.
  46. #
  47. # A cap is also not needed to reach a 1.2-only peer. Measured
  48. # against a local TLS-1.2-only server with the same context this
  49. # module builds: an uncapped client negotiates 1.2 and connects.
  50. # A client forced to 1.3 gets TLSV1_ALERT_PROTOCOL_VERSION — never
  51. # WRONG_VERSION_NUMBER, which comes from bytes that are not a TLS
  52. # record at all. See
  53. # ``tests/unit/services/test_cleartext_probe_2780.py``, which pins
  54. # both measurements so this comment stays falsifiable.
  55. #
  56. # So the entries below are kept as tuning slots and as a record of
  57. # what each reporter saw, not because the mechanism is understood.
  58. # Two of the three explain a symptom this cap cannot affect; see
  59. # their own comments.
  60. # (P1S untested; no claim made either way.)
  61. #
  62. # **Defaults to False** — only applied to printer models where a
  63. # reporter has confirmed the symptom. This is deliberately
  64. # conservative; flipping a printer to the capped path is a config
  65. # edit when a new model surfaces the same bug.
  66. cap_tls_v1_2: bool = False
  67. # ---------------------------------------------------------------------------
  68. # Profile registry
  69. # ---------------------------------------------------------------------------
  70. # Default profile = historical behaviour. Used for every model that
  71. # doesn't have an entry in ``_PROFILES``.
  72. DEFAULT_PROFILE = FTPProfile()
  73. # Per-model overrides. Keys are uppercase display names (e.g. "P2S")
  74. # AFTER alias normalisation, so internal SSDP codes ("N7") resolve via
  75. # ``_MODEL_ALIASES`` below.
  76. _PROFILES: dict[str, FTPProfile] = {
  77. # P2S firmware 01.02.00.00 (#1401, reporter @iitazz). Symptom is a
  78. # 426 truncation part-way through a transfer, on the data channel —
  79. # a different failure from the handshake ones below, and the only
  80. # one here whose mechanism a TLS-1.3 session-ticket problem could
  81. # actually explain. The reporter confirmed the fix.
  82. #
  83. # Unresolved: @grolmus's six P2S units refuse TLS 1.3 outright
  84. # (#2780), so on their firmware the negotiated version was already
  85. # 1.2 and this cap changes nothing. Either the firmware moved
  86. # between the two reports, or #1401 was fixed by something else in
  87. # the same change. Kept because a reporter confirmed it and no one
  88. # has hardware to re-test it on.
  89. "P2S": FTPProfile(
  90. cap_tls_v1_2=True,
  91. ),
  92. # X2D firmware 01.01.00.00 fails the implicit-FTPS handshake on
  93. # port 990 with ``[SSL: WRONG_VERSION_NUMBER]`` (#1638, reporter
  94. # @vasmarfas). Without the 3MF download the print falls through to
  95. # the no-3MF fallback archive path and the card lands almost empty
  96. # (no filament total, no layers, no MakerWorld link).
  97. #
  98. # RE-TEST WANTED. This was capped on the reading that the error came
  99. # from "Python 3.13's default TLS-1.3 ClientHello". That reading is
  100. # now measured wrong: WRONG_VERSION_NUMBER is what a *non-TLS*
  101. # answer produces, a version mismatch reports itself differently,
  102. # and an uncapped client reaches a 1.2-only peer unaided (#2780).
  103. # So this cap cannot be what changed the outcome, and the X2D is
  104. # most likely answering :990 with something that is not TLS — the
  105. # cleartext probe in ``bambu_ftp`` will now say what. Left in place
  106. # rather than removed: nobody here has an X2D, and the entry costs
  107. # nothing on a printer that does not offer 1.3 anyway.
  108. "X2D": FTPProfile(
  109. cap_tls_v1_2=True,
  110. ),
  111. # H2C firmware 01.02.00.00 (#2582, reporter @gyrene2083). The sliced
  112. # 3MF intermittently fails to come off the printer over FTPS, so the
  113. # print drops to the no-3MF fallback archive with no slice data —
  114. # which is why the Print Log shows no filament and nothing is
  115. # deducted.
  116. #
  117. # RE-TEST WANTED, same reasoning as the X2D above. Capped "by
  118. # analogy with P2S" on the belief that the profile-less path "ran on
  119. # the Python-default TLS 1.3"; measurement says a 1.2-only peer
  120. # negotiates 1.2 without a cap, so there was no 1.3 to fall back
  121. # from (#2780). "Intermittent" now points somewhere better: it is
  122. # the signature of the transient non-TLS refusal @grolmus sees on
  123. # his P2S units, which is the same H2 firmware line.
  124. "H2C": FTPProfile(
  125. cap_tls_v1_2=True,
  126. ),
  127. }
  128. # SSDP internal codes that should resolve to a display-name profile.
  129. # Mirrors the same map in :mod:`camera_profiles`.
  130. _MODEL_ALIASES: dict[str, str] = {
  131. "N7": "P2S", # P2S internal SSDP code
  132. "N6": "X2D", # X2D internal SSDP code
  133. "O1C": "H2C", # H2C internal SSDP code
  134. "O1C2": "H2C", # H2C dual-nozzle variant SSDP code
  135. }
  136. def get_ftp_profile(model: str | None) -> FTPProfile:
  137. """Return the :class:`FTPProfile` for *model*, or the default.
  138. ``model`` can be either a display name (e.g. ``"P2S"``) or an
  139. internal SSDP code (e.g. ``"N7"``). Unknown / missing models fall
  140. back to :data:`DEFAULT_PROFILE` so the FTP path is never blocked
  141. on a missing entry.
  142. """
  143. if not model:
  144. return DEFAULT_PROFILE
  145. key = model.upper().strip()
  146. key = _MODEL_ALIASES.get(key, key)
  147. return _PROFILES.get(key, DEFAULT_PROFILE)