diff --git a/CHANGELOG.md b/CHANGELOG.md index c311d3d7f..9f8815f4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ All notable changes to Bambuddy will be documented in this file. - **VP MQTT bridge surfaces why `net.info[].ip` rewrite didn't arm (#1429 defensive)** — `MQTTBridge._refresh_ip_encoding` had 4 silent early-return paths (`target_client is None`, `printer client has no ip_address yet`, `no host interface shares a subnet with printer IP X and bind_address is 0.0.0.0/empty`, `invalid IPv4 …`). When the rewrite silently no-op'd on a user's setup, the only signal was the absence of the `MQTT bridge IP encoding armed` INFO line — diagnosing which path was firing meant grepping the source. Each path now emits one `MQTT bridge IP encoding NOT armed: ` INFO line; the message names the actual failure (target IP, the missing-interface case, etc.). Throttled via a `_not_armed_reason` dedup field so an idle unarmed bridge doesn't spam one line per 30s refresh tick — only state changes log. Cleared on successful arm so a regression (e.g. printer client unbinds) re-emits the diagnostic. 5 new tests in `TestNotArmedDiagnosticLogging` pin each path's specific reason text, the once-per-state-change throttle, and the arm-clears-dedup behaviour. **Not a fix for #1429 itself** — the bridge logic is unchanged; this just turns the silent failure into visible signal so the next "fix didn't work for me" report can be triaged in one round-trip instead of multiple. ### Fixed +- **X2D archives lose 3MF metadata because FTPS handshake fails on firmware 01.01.00.00 (#1638, reported by @vasmarfas)** — Reporter's first archive entries from a brand-new X2D landed almost empty (only print time visible, no filament weight / layers / MakerWorld link / thumbnail), and Spoolman filament-usage tracking also went silent. The support bundle traces the symptom end-to-end: at print start `backend/app/main.py::on_print_start` tries the usual FTP-download dance for the 3MF, every connect attempt to the printer fails with `[SSL: WRONG_VERSION_NUMBER] wrong version number (_ssl.c:1032)`, and ~2 minutes later `Could not find 3MF file for print: /data/Metadata/plate_1.gcode` → `Created fallback archive N for (no 3MF available)`. The fallback path writes the row with `file_path=""`, `file_size=0`, `content_hash=NULL`, and no layers / filament / model-link fields — exactly the "almost empty card" in the reporter's screenshot. Spoolman tracking and reprint-grouping also degrade from the same root cause: both depend on metadata pulled out of the 3MF by `ThreeMFParser`. The proximate cause is the FTPS handshake: Python 3.13's default `ssl.create_default_context()` negotiates TLS 1.3, and the X2D's implicit-FTPS server on port 990 rejects the ClientHello with `WRONG_VERSION_NUMBER`. This is the same shape of symptom as the P2S 01.02.00.00 FTPS bug from #1401 — handshake / data-channel breakage triggered by the move to Python 3.13's TLS-1.3 default — but the wire-level failure mode is different (P2S completes the handshake and truncates mid-stream with 426; X2D fails the handshake outright). Both are addressed via the per-model registry that #1401 established: `backend/app/services/ftp_profiles.py` gains an `X2D` entry with `cap_tls_v1_2=True` plus a `N6 → X2D` SSDP alias, so the X2D's `ImplicitFTP_TLS` connection caps the SSL context's `maximum_version` to TLS 1.2 and the ClientHello looks like the one the firmware accepted before the Python upgrade. Deliberately conservative — every other model stays on negotiated TLS 1.3, only X2D-tagged sessions flip. **Honest caveat**: this ships as a hypothesis-driven trial rather than a confirmed root-cause fix. The TLS-1.2 cap is the most likely cure given the symptom's family resemblance to #1401, but `WRONG_VERSION_NUMBER` could equally describe the X2D switching to explicit FTPS (AUTH TLS on a plaintext greeting) or moving the FTPS service to a different port — both would need a different code path. The reporter has been asked to test this build; if the cap doesn't clear the error, the registry slot stays useful as a tuning anchor and the next round of diagnostics (`openssl s_client -connect :990 -tls1_2` from a network-adjacent host) will tell us which of (2)/(3) applies. **Tests**: 3 new in `test_ftp_profiles.py` mirroring the existing P2S coverage — `X2D` resolves to `cap_tls_v1_2=True`, `N6` SSDP code aliases to the X2D profile, lowercase `x2d` still hits the cap. Existing P2S + default + unknown-model + frozen-dataclass + non-capped-spot-check (X1C / H2D / P1S / A1) tests stay green. **Verified**: ruff clean; the integration test at `test_cap_tls_v1_2_actually_applied_to_ssl_context` already pins the profile→`ImplicitFTP_TLS`→`ssl_context.maximum_version` wiring so this entry can't silently fail to apply. + - **Label printing produced two identical PDFs per click (#1628)** — `LabelTemplatePickerModal.tsx::openBlobInNewTab` called `window.open(url, '_blank', 'noopener,noreferrer')` and treated a `null` return as "popup blocked → fall back to `` click." Per the WindowFeatures spec, `noopener` deliberately forces `window.open` to return `null` even on success, so the `if (!win)` fallback fired on EVERY click. Path 1 (window.open) opened the blob tab — on Linux Chromium without an inline PDF viewer the OS saved a random-named copy (the `zo70GhSL.pdf` / `f7w0OcDi.pdf` files in the reporter's screenshot). Path 2 (fallback) downloaded a second copy named `bambuddy-labels.pdf`. Two identical PDFs per click. Fix: drop `noopener,noreferrer`. The blob is same-origin (created via `URL.createObjectURL` from our own fetch response), the destination is a passive PDF preview tab with no script context to abuse `window.opener`, and `noreferrer` is a no-op for blob URLs. After removal, `window.open` returns a real window reference on success → `if (!win)` only fires on genuine popup-block, single PDF per click. Existing 17 vitest cases in `LabelTemplatePickerModal.test.tsx` still pass; the change is comment + one parameter. ### Changed diff --git a/backend/app/services/ftp_profiles.py b/backend/app/services/ftp_profiles.py index 0f553d3a0..489bb39e9 100644 --- a/backend/app/services/ftp_profiles.py +++ b/backend/app/services/ftp_profiles.py @@ -75,12 +75,25 @@ _PROFILES: dict[str, FTPProfile] = { "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 } diff --git a/backend/tests/unit/services/test_ftp_profiles.py b/backend/tests/unit/services/test_ftp_profiles.py index 40592fcd1..d12309e68 100644 --- a/backend/tests/unit/services/test_ftp_profiles.py +++ b/backend/tests/unit/services/test_ftp_profiles.py @@ -43,10 +43,26 @@ def test_p2s_internal_ssdp_code_resolves_to_p2s(): assert profile.cap_tls_v1_2 is True +def test_x2d_caps_tls_v1_2(): + """X2D firmware 01.01.00.00 fails implicit-FTPS handshake on port + 990 with WRONG_VERSION_NUMBER against Python 3.13's TLS-1.3 default + (#1638, reporter @vasmarfas). The profile caps to TLS 1.2 by + analogy with P2S.""" + profile = get_ftp_profile("X2D") + assert profile.cap_tls_v1_2 is True + + +def test_x2d_internal_ssdp_code_resolves_to_x2d(): + """SSDP internal code N6 → X2D profile.""" + profile = get_ftp_profile("N6") + assert profile.cap_tls_v1_2 is True + + def test_lookup_is_case_insensitive(): """Printer.model may carry mixed case; the lookup normalises.""" assert get_ftp_profile("p2s").cap_tls_v1_2 is True assert get_ftp_profile("P2s").cap_tls_v1_2 is True + assert get_ftp_profile("x2d").cap_tls_v1_2 is True def test_non_capped_models_still_default():