Files
bambuddy/backend/app/services/ftp_profiles.py
T
maziggy e802bfc806 fix(ftp): cap TLS to v1.2 for X2D FTPS to dodge WRONG_VERSION_NUMBER on firmware 01.01.00.00 (#1638)
Reporter @vasmarfas saw X2D archive cards land almost empty - only print
  time, no filament weight / layers / MakerWorld link / thumbnail - and
  Spoolman filament-usage tracking went silent on the same printer.

  Support bundle traces the end-to-end: at print start
  backend/app/main.py::on_print_start tries the usual FTP-download dance
  for the 3MF, every implicit-FTPS connect attempt to the X2D fails with
  `[SSL: WRONG_VERSION_NUMBER] wrong version number (_ssl.c:1032)`, ~2
  minutes later "Could not find 3MF file for print" -> "Created fallback
  archive". Fallback path writes file_path="", file_size=0,
  content_hash=NULL, no layers / filament / model-link fields. Spoolman
  tracking degrades from the same root cause - both depend on the 3MF
  metadata parser.

  Proximate cause: Python 3.13's default ssl.create_default_context()
  negotiates TLS 1.3, the X2D's implicit-FTPS server on port 990 rejects
  the ClientHello. Same family as the P2S 01.02.00.00 bug from #1401
  (post-Python-3.13 TLS-1.3 breakage), different wire-level failure mode
  (P2S completes the handshake and truncates with 426; X2D fails the
  handshake outright).

  Same fix shape: add X2D to backend/app/services/ftp_profiles.py with
  cap_tls_v1_2=True, plus N6 -> X2D SSDP alias. Every other model stays
  on negotiated TLS 1.3.

  Honest caveat: hypothesis-driven trial, not a confirmed root-cause fix.
  WRONG_VERSION_NUMBER could equally describe the X2D switching to
  explicit FTPS (AUTH TLS on plaintext greeting) or moving FTPS to a
  different port - either would need a different code path. Reporter has
  been asked to test this build; if the cap doesn't clear it the registry
  slot stays useful and the next diagnostic round goes to openssl
  s_client from a network-adjacent host.
2026-06-05 08:30:19 +02:00

113 lines
4.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Per-printer-model FTP tuning knobs.
Mirrors the shape of :mod:`backend.app.services.camera_profiles` — a
small registry of per-model overrides so quirky firmwares can be
tuned without sprinkling ``if model == "X":`` branches through
``bambu_ftp.py``. Adding a new model's quirk is a config edit (an
entry in ``_PROFILES`` plus the alias for its internal SSDP code if
needed), not another hard-coded branch.
The default profile matches the historical pre-fix behaviour, so
every model that doesn't have an entry here keeps its existing FTP
behaviour byte-for-byte.
Currently only the TLS-version cap lives here (P2S firmware
01.02.00.00 needs it — see ``cap_tls_v1_2`` below). The A1
data-channel-plaintext quirk still lives in :class:`BambuFTPClient`
via ``A1_MODELS`` / ``skip_session_reuse``; folding that into a
profile field is a future cleanup, not load-bearing for this fix.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class FTPProfile:
"""Tuning knobs for one printer model's FTP path.
All defaults reflect the historical behaviour. Models with quirky
firmware override individual fields rather than re-defining the
whole profile.
"""
# Pin the SSL context's ``maximum_version`` to TLS 1.2.
#
# Python 3.13's default ``ssl.create_default_context()`` negotiates
# TLS 1.3 when both peers support it. The Bambuddy Docker image is
# ``python:3.13-slim-trixie``, so every Docker user gets 1.3 by
# default. Some Bambu printer firmwares (P2S 01.02.00.00 confirmed
# by @iitazz, #1401) implement session reuse on the FTPS data
# channel against an old vsFTPd build that doesn't tolerate TLS
# 1.3's asynchronous session-ticket model: the data channel gets
# torn down mid-stream and the upload aborts with 426 "Failure
# reading network stream" — visible as a clean truncation at a
# chunk boundary (one reporter saw exactly 7 × 64 KB landed on
# the printer). Capping to TLS 1.2 makes session resumption
# synchronous and the upload completes normally.
#
# **Defaults to False** — only applied to printer models where a
# reporter has confirmed the symptom. Existing P1S / X1C / H2D
# installs that work fine today stay on the negotiated TLS 1.3.
# This is deliberately conservative; flipping a printer to the
# capped path is a config edit when a new model surfaces the
# same bug.
cap_tls_v1_2: bool = False
# ---------------------------------------------------------------------------
# Profile registry
# ---------------------------------------------------------------------------
# Default profile = historical behaviour. Used for every model that
# doesn't have an entry in ``_PROFILES``.
DEFAULT_PROFILE = FTPProfile()
# Per-model overrides. Keys are uppercase display names (e.g. "P2S")
# AFTER alias normalisation, so internal SSDP codes ("N7") resolve via
# ``_MODEL_ALIASES`` below.
_PROFILES: dict[str, FTPProfile] = {
# P2S firmware 01.02.00.00 trips the vsFTPd + TLS 1.3 session-reuse
# bug on the FTPS data channel (#1401, reporter @iitazz). Cap to
# TLS 1.2 so session resumption is synchronous and the upload
# completes.
"P2S": FTPProfile(
cap_tls_v1_2=True,
),
# X2D firmware 01.01.00.00 fails the implicit-FTPS handshake on
# port 990 with ``[SSL: WRONG_VERSION_NUMBER]`` against Python
# 3.13's default TLS-1.3 ClientHello (#1638, reporter @vasmarfas).
# Without the 3MF download the print falls through to the no-3MF
# fallback archive path and the card lands almost empty (no
# filament total, no layers, no MakerWorld link). Cap to TLS 1.2
# by analogy with P2S; if the symptom turns out to be a different
# FTPS variant on the X2D (explicit AUTH TLS, different port) the
# entry stays useful as a per-model tuning slot for the follow-up.
"X2D": FTPProfile(
cap_tls_v1_2=True,
),
}
# SSDP internal codes that should resolve to a display-name profile.
# Mirrors the same map in :mod:`camera_profiles`.
_MODEL_ALIASES: dict[str, str] = {
"N7": "P2S", # P2S internal SSDP code
"N6": "X2D", # X2D internal SSDP code
}
def get_ftp_profile(model: str | None) -> FTPProfile:
"""Return the :class:`FTPProfile` for *model*, or the default.
``model`` can be either a display name (e.g. ``"P2S"``) or an
internal SSDP code (e.g. ``"N7"``). Unknown / missing models fall
back to :data:`DEFAULT_PROFILE` so the FTP path is never blocked
on a missing entry.
"""
if not model:
return DEFAULT_PROFILE
key = model.upper().strip()
key = _MODEL_ALIASES.get(key, key)
return _PROFILES.get(key, DEFAULT_PROFILE)