fix(ftp): cap H2C FTPS to TLS 1.2 so the sliced 3MF downloads reliably (#2582)

H2C (firmware 01.02.00.00) had no per-model FTP profile and ran on the
Python-default TLS 1.3, hitting the same vsFTPd session-reuse fault the P2S
(#1401) and X2D (#1638) were already capped for. The intermittent FTPS
failure dropped prints to the no-3MF fallback archive, so slice data was
missing — hence no filament in the Print Log and no inventory deduction.
Add an H2C cap_tls_v1_2 profile plus its O1C/O1C2 SSDP aliases. H2D is left
on the default profile (negotiates TLS 1.3 without the fault).
This commit is contained in:
maziggy
2026-07-18 09:33:22 +02:00
parent cc75a24371
commit 9174badb10
3 changed files with 32 additions and 0 deletions
+1
View File
@@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Orca Cloud profile sync now connects by approving a code instead of the copy-paste sign-in** — Connecting Bambuddy to Orca Cloud used to mean opening an OAuth sign-in in a new tab, watching it redirect to a `localhost` URL that fails to load, then copying that dead URL out of the address bar and pasting it back into Bambuddy. That dance existed only because Orca's auth backend (Supabase) accepts no redirect target other than `localhost`, and the deliberately-broken redirect page confused nearly everyone who reached it. OrcaSlicer has since shipped a first-class external-app pairing API (the OAuth 2.0 Device Authorization Grant, RFC 8628), so the flow is now: click **Connect**, approve a short code on your Orca Cloud settings page, and Bambuddy pairs itself — no redirect, no paste, no client secret, and it behaves identically from a LAN IP, `localhost`, or behind a reverse proxy. Bambuddy requests **read-only** access (it only lists and views your Orca Cloud profiles), keeps the pairing alive with the API's rotating refresh tokens (validated end-to-end against Orca's staging and production servers), and stores nothing beyond the issued token pair. The profile list and detail views are unchanged, so nothing downstream of the connect step looks different. The old paste-based sign-in and the email/password fallback are removed. Points at production Orca Cloud by default; `ORCA_CLOUD_API_BASE` overrides the endpoint for testing.
### Fixed
- **H2C prints intermittently recorded no filament and never deducted from inventory (#2582, reporter @gyrene2083)** — On an H2C (firmware `01.02.00.00`) filament usage sometimes wasn't deducted and the Print Log showed no filament for that print; the reporter confirmed the tell-tale detail — the failed print's archived `.3mf` didn't exist to download. Filament totals, the Print Log filament column, and the weight deduction all read the sliced 3MF's data, so when that file can't be pulled off the printer the print drops to the no-3MF fallback archive and every one of them comes up empty. The download itself was the failure: the H2C is the same H2 generation and the same firmware line as the P2S, whose FTPS data channel trips a vsFTPd + TLS 1.3 session-reuse bug on Python 3.13 (#1401) — and the X2D hit the sibling handshake variant (#1638). Both were fixed by capping that model's FTP control/data channel to TLS 1.2 via the per-model FTP profile registry, but the H2C had no entry and so ran on the Python-default TLS 1.3, leaving its 3MF downloads to fail the same way (intermittently, matching the "sometimes works, sometimes doesn't" report — the session-reuse race rather than a hard handshake failure). The H2C now gets the same `cap_tls_v1_2` profile as the P2S/X2D (with its `O1C`/`O1C2` SSDP codes mapped to it), so the sliced 3MF comes off the printer reliably and the slice data — filament total, Print Log filament, and the inventory deduction — is populated again. H2D is deliberately left on the default profile; it negotiates TLS 1.3 without this fault.
- **An unresolved AMS mapping silently dispatched a P1S print to the empty external spool (#2589, reporter @Jostxxl)** — A queued P1S job with a regular AMS attached, two compatible PETG spools loaded, and nothing on the external spool holder started against the *external* feed and paused seconds later with a filament-runout HMS. The queue row was correct on its face — `use_ams=true` — but carried `ams_mapping=[-1]`, and Bambuddy turned that into a print with no AMS. Two faults combined. **A `-1` was read as "external spool."** The command builder's rule for "all slots are external, so drop `use_ams`" tested `t < 0 or t >= 254` — folding the *unresolved* sentinel (`-1`) in with a genuine external selection (`254`/`255`). An explicit external print serializes as `[254]`; an unresolved slot serializes as `[-1]`, and the two mean opposite things — one is "use the spool holder", the other is "we never worked out which tray." Only `>= 254` may now force `use_ams=False`; `-1` never does. **The unresolved mapping was trusted instead of recomputed.** The scheduler only computes a mapping when the row has *none*; a stored `[-1]` is non-empty, so it looked "already resolved" and was passed through verbatim — even though the backend had the live AMS trays and the plate's filament requirements right there and could have matched them. Dispatch now recomputes whenever the stored mapping is entirely unresolved, so a bogus `[-1]` self-heals against the trays actually loaded (and any pre-existing stuck row heals on the next scheduler pass); if nothing compatible is loaded it is cleared rather than sent, so the firmware reports a clear mapping error instead of quietly printing to an empty feed. **Where the `[-1]` came from.** The Print dialog builds the mapping from the selected printer's live status; if you submitted a single-printer job in the instant before that status query resolved, it matched against zero known trays and serialized every required slot as `-1`. The dialog now waits for the printer's AMS status before it will submit (showing a brief "Waiting for AMS status from …" notice), and the mapping hook returns *no* mapping rather than an all-`-1` one while the trays are unknown — so the scheduler resolves it at dispatch. A genuine no-match with trays present still serializes `-1` and surfaces the mismatch as before. **Tests.** Backend: the command builder keeps `use_ams=true` for `[-1]`/`[-1,-1]` and a padded `[-1,-1,5]`, still drops it for an explicit `[254]`; the scheduler recomputes a stored `[-1]`, leaves a resolved (or manually-overridden) mapping untouched, and clears an unresolvable one. An existing test that asserted the old `[-1] → use_ams=False` behaviour was corrected to the fixed contract. Frontend: the mapping hook returns `undefined` while status is loading, resolves to the AMS tray once it arrives (type-only match with strict colour off), and still emits `-1` for a real mismatch. Full backend suite and the PrintModal/mapping frontend suites green.
- **Pushover Emergency priority (2) was rejected by the Pushover API (#2586)** — Setting a Pushover provider to priority 2 (Emergency) made every notification fail with Pushover's own error that `retry` and `expire` are required. Pushover *mandates* those two parameters for Emergency alerts — `retry` is how often it re-alerts (minimum 30 s) and `expire` is when it stops (maximum 10800 s / 3 h) — and Bambuddy never sent them, so the message was refused before it left the app. Priority 2 now works: two new optional fields (Emergency Retry / Expire) appear on the Pushover provider **only when priority is set to 2**, default to a sensible 60 s / 3600 s, are clamped to Pushover's legal 30–10800 s range, and are sent only at priority 2 (Pushover ignores them at other priorities). Emergency alerts now keep re-alerting until acknowledged, as intended.
- **P2S RTSP timeout could leave the fan-out camera stream permanently stalled (#2580, reported and diagnosed by @ronaldheft, fix shape from PR #2581)** — After an RTSP read timeout, the stream cleanup killed the stalled ffmpeg and then waited *unbounded* for it to be reaped. A SIGKILLed ffmpeg stuck in uninterruptible I/O on a dead RTSP socket can take arbitrarily long to exit, so the fan-out stream coroutine sat parked in that wait — in the reported case for 12 hours — while every new viewer attached to the stalled broadcaster and got no frames (snapshots and diagnostics kept working, since those open fresh connections). The post-kill wait is now bounded (2 s): on timeout the stream abandons the zombie — the orphan janitor's /proc scan reaps it on its next pass — and proceeds to its normal reconnect, so live view recovers by itself. The same unbounded wait hid in two more places, both bounded too: the camera *Stop* endpoint (which would hang the very request a user makes to recover a stuck stream) and the periodic orphan-cleanup janitor itself (which is the safety net that recovers stalled streams, and so can least afford to block).
+15
View File
@@ -91,6 +91,19 @@ _PROFILES: dict[str, FTPProfile] = {
"X2D": FTPProfile(
cap_tls_v1_2=True,
),
# H2C firmware 01.02.00.00 (#2582, reporter @gyrene2083) — same H2
# generation and same firmware line as P2S, and with no profile it
# ran on the Python-default TLS 1.3. Reported symptom is exactly the
# one the X2D comment describes: the sliced 3MF intermittently fails
# to come off the printer over FTPS, so the print drops to the no-3MF
# fallback archive with no slice data — which is why the Print Log
# shows no filament and nothing is deducted. Cap to TLS 1.2 by analogy
# with P2S (intermittent "sometimes works" points at the session-reuse
# variant, not X2D's deterministic handshake failure); if a debug
# capture shows a different FTPS variant the entry stays the tuning slot.
"H2C": FTPProfile(
cap_tls_v1_2=True,
),
}
# SSDP internal codes that should resolve to a display-name profile.
@@ -98,6 +111,8 @@ _PROFILES: dict[str, FTPProfile] = {
_MODEL_ALIASES: dict[str, str] = {
"N7": "P2S", # P2S internal SSDP code
"N6": "X2D", # X2D internal SSDP code
"O1C": "H2C", # H2C internal SSDP code
"O1C2": "H2C", # H2C dual-nozzle variant SSDP code
}
@@ -58,6 +58,22 @@ def test_x2d_internal_ssdp_code_resolves_to_x2d():
assert profile.cap_tls_v1_2 is True
def test_h2c_caps_tls_v1_2():
"""H2C firmware 01.02.00.00 — same H2 generation / firmware line as
P2S — intermittently fails the FTPS 3MF download and falls through to
the no-3MF fallback archive, so nothing gets deducted (#2582, reporter
@gyrene2083). The profile caps to TLS 1.2 by analogy with P2S."""
profile = get_ftp_profile("H2C")
assert profile.cap_tls_v1_2 is True
def test_h2c_internal_ssdp_codes_resolve_to_h2c():
"""SSDP internal codes O1C and O1C2 (dual-nozzle variant) → H2C
profile, so the cap applies however the model string arrives."""
assert get_ftp_profile("O1C").cap_tls_v1_2 is True
assert get_ftp_profile("O1C2").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