github maziggy/bambuddy v0.2.5b1-daily.20260520
Daily Beta Build v0.2.5b1-daily.20260520

pre-release4 hours ago

Note

This is a daily beta build (2026-05-20). It contains the latest fixes and improvements but may have undiscovered issues.

Docker users: Update by pulling the new image:

docker pull ghcr.io/maziggy/bambuddy:daily

or

docker pull maziggy/bambuddy:daily


**Tip:** Use [Watchtower](https://containrrr.dev/watchtower/) to automatically update when new daily builds are pushed.

Added

  • Currency: Belize Dollars (BZD) added to the Settings → Cost currency dropdown (#1454, requested by PLGuerraDesigns) — Reporter accurately tracks 3D-printing filament costs in his local currency and BZD wasn't selectable, forcing a manual 2:1 mental conversion from USD. Added BZD: 'BZ$' to frontend/src/utils/currency.ts next to MXN (Americas dollar-prefix grouping); getCurrencySymbol('BZD') returns 'BZ$' and the SUPPORTED_CURRENCIES list now has 30 entries. Unit test added in frontend/src/__tests__/utils/currency.test.ts covering the symbol lookup and presence in SUPPORTED_CURRENCIES; entry-count assertion bumped to 30 so any future additions/removals are caught immediately. 14 currency tests green; frontend build clean.

Changed

  • Slice modal: cross-printer 3MFs now re-slice transparently, banner removed, modal fully i18n'd — Previous behaviour disabled the Slice button whenever the source 3MF's bound printer model didn't match the user's picked printer profile, on the theory that the slicer CLI "cannot re-slice a 3MF for a different printer" and would silently fall back to embedded settings to produce a wrong-printer file. Step 0 empirical test on 2026-05-20 disproved that: an 18-color H2D-bound Trent900.3mf sliced via the X1C bundle (POST /slice with bundle=cb…X1C, printerName=# Bambu Lab X1 Carbon 0.4 nozzle) produced 2.3 MB of genuinely X1C-compatible G-code in 1.8 s — printer_model overridden to Bambu Lab X1 Carbon, printable_area to 256×256 (X1C bed, not H2D's 350×320), printable_height 250 (vs 325), bed_exclude_area populated with X1C's 18×28 corner zone, nozzle_diameter single 0.4 (vs H2D's dual 0.4,0.4), and the full X1C machine_start_gcode sequence baked in. The sidecar takes printer / process / first-N filament names from the picked bundle and only inherits embedded values for unused trailing slots — bed size, kinematics, start sequence all come from the target. Behavioural change: dropped !printerMismatch from the SliceModal isReady predicate so the Slice button stays enabled when models differ. The amber banner was first softened to an info message, then removed entirely — re-slicing across printers is now just a normal slice, the picker UI already shows which printer was picked, no second confirmation needed. Dead-code removal (same drop): with no banner, the source_printer_model field on the /library/files/{id}/plates and /archives/{id}/plates responses had zero consumers; the extract_source_printer_model_from_3mf helper in threemf_tools.py (which opened the 3MF zip and read Metadata/project_settings.config on every plate request) had zero callers. Removed both response keys, both backend extractions, both threemf_tools imports, the helper itself, its 6 unit tests, the source_printer_model field from frontend/src/types/plates.ts (PlateMetadata + LibraryFilePlatesResponse), and 2 obsolete SliceModal tests that exercised the now-impossible matched-printer / legacy-archive paths. i18n discipline cleanup (same drop, per [[feedback_no_followups]] + [[feedback_translate_dont_fallback]]): every t() callsite in SliceModal.tsx had an inline English defaultValue: or positional-second-arg English fallback — 22 sites in total. With 8 locales shipped, those fallbacks are dead weight at best, and an actual i18n-violation when the key is missing because non-English users would silently see English. Audit found 3 keys (slice.bundle, slice.bundleNone, slice.bundleAllRequired) that had no corresponding entry in any locale file — they were being served from the inline English fallback exclusively, meaning every non-English user was already seeing those three labels in English. Added all 3 to all 8 locales with real translations, then stripped the English fallback from every t() call in SliceModal.tsx. The slice.printerMismatch key was removed from all 8 locales (banner is gone). Why this matters: a recurring pain point for users importing MakerWorld project files where the original creator's printer often differs from the user's; previously they had to round-trip through BambuStudio's "convert project" flow to re-export. Now Bambuddy re-slices in-place with no UI friction. Tests: the existing SliceModal "shows mismatch warning AND disables Slice" test was rewritten to assert "does not surface any cross-printer banner AND keeps Slice enabled when models differ" (regression guard against the gate being re-added); 2 obsolete tests deleted. 32 SliceModal tests green (was 34, -2 dead tests); 49 threemf_tools tests green (was 55, -6 helper tests); 24 plates-route tests green; frontend build clean; backend ruff clean; i18n parity check passes 4858 keys × 8 locales (net +2 vs pre-fix: +3 bundle keys, -1 printerMismatch).

Security

  • idna: bump to >=3.15 to clear CVE-2026-45409 (ReDoS in idna.encode() with crafted Unicode payloads, e.g. "٠" * N or "・" * N + "漢") — Transitive dep pulled in by anyio / httpx / requests / yarl; not directly pinned, which is why it lingered at 3.13. Added an explicit idna>=3.15 floor in requirements.txt between Authentication and HTTP-client blocks with a comment explaining why it's pinned (so a future downstream loosening doesn't silently downgrade us). Verified via pip-audit clean post-upgrade.
  • PyJWT CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): permanently ignored in pip-audit — Advisory is disputed by the PyJWT maintainers, with the advisory description literally noting "this is disputed by the Supplier because the key length is chosen by the application that uses the library." fix_versions=[] on the advisory confirms no PyJWT patch exists or will exist. Bambuddy is not affected: backend/app/core/auth.py:184 auto-generates secrets via secrets.token_urlsafe(64) (~86 chars of entropy, far above any sane minimum) and the file-loaded path at :177 rejects secrets shorter than 32 chars. Added a permanent --ignore-vuln CVE-2025-45768 to .github/workflows/security.yml with an inline comment citing the file:line evidence so a future maintainer reviewing the ignore list sees why it's load-bearing. Also dropped the stale --ignore-vuln CVE-2026-4539 for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified: pip-audit --ignore-vuln CVE-2025-45768 alone reports clean).

Fixed

  • Scheduler: queue items with force_color_match filament overrides now produce a correct AMS mapping at dispatch (#1437, fixed by external PR #1440 from Person2099) — Contributor's own bug report and fix. He had a queue item with filament_overrides: [{slot_id: 1, type: "PLA", color: "#CBC6B8", force_color_match: true}] and ams_mapping: null, expecting Bambuddy to translate the override into a slot mapping at dispatch time. Instead the scheduler dispatched with ams_mapping: null and the P1S fell back to type-only AMS matching, picking the wrong-colour slot. Two-layer root cause he traced end-to-end. (1) backend/app/services/filament_requirements.py:69: extract_filament_requirements(file_path, plate_id=None) fell through to _collect_filaments(root, filaments) whose XPath ./filament only matches direct children of <config>. Modern BambuStudio 3MFs wrap filaments inside <plate> elements, so this XPath returned [] on every modern multi-plate 3MF when no specific plate was targeted — which is the standard scheduler call shape for queue items without a pinned plate. The downstream "no AMS mapping" cascade ALL flowed from this empty filament_reqs result. Fix walks <plate> elements first, dedupes by slot_id (highest used_grams wins on ties — sane because BambuStudio slots are project-wide and the entry that extruded the most is the most representative for AMS planning), and preserves the old ./filament XPath as a fallback when no <plate> elements are present, so legacy 3MFs continue to parse unchanged. (2) backend/app/services/print_scheduler.py:792 — defence in depth: even with (1) in place, edge cases exist where _get_filament_requirements can still return None (3MF missing slice_info.config entirely, IO failure during ZIP extraction, etc). New _build_override_direct_mapping(force_overrides, status) helper kicks in at exactly that moment when force_color_match overrides are present — builds the requirement list directly from the overrides (slot_id, type, color, empty tray_info_idx) and delegates to the existing _match_filaments_to_slots() cascade against the printer's loaded AMS state. Wrong-colour slot credit via the cascade's type-only fallback is impossible-by-construction because the upstream _get_missing_force_color_slots() printer-eligibility gate at :590 already requires an exact (type, normalised colour) pair to be loaded before the printer is even considered for the job, so by the time _build_override_direct_mapping runs the exact match is guaranteed in the loaded set and the cascade's exact_match branch wins (colour normalisation is identical on both sides — tray_color.replace("#", "").lower()[:6]). Pref-only overrides (without force_color_match) intentionally do NOT trigger the fallback — they keep the pre-PR "no mapping, printer picks defaults" behaviour, so the new fallback is strictly opt-in via force_color_match: true. Backwards-compat triple-checked: legacy 3MF format unchanged (preserved fallback path); plate_id != None branch untouched (entire fix is inside the else of if plate_id is not None); filament_overrides=None / [] / no-force-entries all preserve the existing return None path; malformed JSON in filament_overrides is caught by the existing try/except, logged, and still returns None. Tests (22 new across two files; all pass on pytest -n 30): backend/tests/unit/services/test_filament_requirements.py — 4 tests covering the plate_id=None modern-format path, multi-plate collection, slot-dedup-by-highest-grams, and single-plate-modern-format. backend/tests/unit/test_scheduler_force_color_ams_fallback.py — 18 tests across TestBuildOverrideDirectMapping (single override matches AMS slot, empty AMS returns None, no colour match still produces a mapping length, multi-override produces multi-element mapping, external spool match yields global_tray_id 254, tray_info_idx is cleared) and TestComputeAmsMappingFallback (fallback used when reqs empty + force overrides present, fallback NOT used when no force_color flag, fallback NOT used when overrides None, normal path still used when reqs available, printer-status-unavailable returns None gracefully). 5079 backend tests + ruff + frontend build all clean post-merge; #1457/#1459/#1440 verified non-interacting (different services, different code paths, different timings). External-PR-checklist (per [[feedback_pr_changelog_required]]): contributor doesn't add CHANGELOG, this entry added by Martin post-merge.
  • Spoolman: per-print weight reporting now works for tag-less spools assigned via the Bambuddy UI (#1459, reported by Moskito99 — follow-up to #1119) — Reporter on Postgres + Postgres-backed Spoolman noticed that prints finished cleanly but the spool's remaining weight in Spoolman was never decremented. He correctly traced it: Spoolman's extra.tag on his spool was empty, and writing a value in there by hand made weight tracking start working. Root cause is one missing fallback path. After #1119 introduced the local spoolman_slot_assignments table as the authoritative binding for tag-less spools (RFID is the binding for Bambu Lab spools, slot-assignment is the binding for generic / non-RFID spools), the Assign UI deliberately leaves Spoolman's extra.tag field empty for those spools — and after the #1457 cleanup we now actively clear it on re-binding to stop ghost links resurfacing in the hover card. That's the correct write-side behaviour. But the per-print weight tracker (backend/app/services/spoolman_tracking.py:_report_spool_usage_for_slots) only resolved the bound spool via client.find_spool_by_tag(spool_tag) — a single tag-lookup against Spoolman's extra.tag. For tag-less spools that returns None and the tracker silently skipped the slot. The tracker never consulted the local spoolman_slot_assignments table that has the answer (verified: grep -n SpoolmanSlotAssignment backend/app/services/spoolman_tracking.py returned zero hits before this fix). So Bambu Lab RFID users got correct weight reporting (their extra.tag is auto-populated by the AMS-sync create_spool path at backend/app/services/spoolman.py:1076), and generic-spool users on Spoolman saw weight tracking silently no-op — exactly the symptom Moskito99 saw. Fix adds a two-stage resolver inside _report_spool_usage_for_slots: stage 1 is the existing client.find_spool_by_tag(spool_tag) (RFID and any RFID-equivalent extra.tag value), stage 2 is the new _resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id) helper that queries the SpoolmanSlotAssignment table for (printer_id, ams_id, tray_id) → spoolman_spool_id. The (ams_id, tray_id) pair is derived from the slot's global_tray_id via the existing _global_tray_id_to_ams_slot helper — same translation used for fallback-tag generation, so external slots (global 254/255 → ams_id=255, tray_id=0/1) and AMS-HT slots (global 128+ → ams_id=global, tray_id=0) all resolve correctly. Stage-1-wins ordering is deliberate: when an RFID-bound spool is in the slot, extra.tag is the authoritative binding, even if the slot-assignment table happens to point at a different spool (legacy state). The resulting [SPOOLMAN] … via tag vs … via slot-assignment suffix in the success log makes it obvious which path resolved each slot, which support bundles will use to confirm the fix is live. printer_id threaded through the three callers (_report_partial_usage G-code path, _report_partial_usage linear path, report_usage) — they all already had printer_id in scope. Crucially, extra.tag is NOT auto-populated by this fix — that would re-introduce exactly the pollution #1457 cleaned up (deterministic fallback tags surviving across spool changes and surfacing stale spools in the hover card). The slot-assignment table is the source of truth for non-RFID bindings; Spoolman's extra.tag is reserved for hardware RFID identifiers. Tests: 5 new in backend/tests/integration/test_spoolman_tracking_slot_fallback.py: the bug repro (tag missing + slot-assignment present → use_spool by the slot-assignment's id); tag-match wins when both present (a regression that flips the resolution order would credit the wrong spool); skip-when-neither (no spool resolution attempted); skip-when-printer_id-not-supplied (legacy call shape stays inert); external-slot translation (global 254 → ams_id=255 tray_id=0 lookup works). New patch_async_session fixture routes the tracker's module-level async_session to the test engine so the in-test SpoolmanSlotAssignment insert is visible to the lookup. Postgres compatibility: verified — the lookup uses a plain select(...where...).scalar_one_or_none(), no SQLite-only syntax. 642 spoolman/tracking tests + 5 new = 647 green; full backend suite 5065 green; ruff clean.
  • Spoolman: AMS hover card and SpoolBuddy fill-bar no longer surface a stale spool after re-assigning a non-RFID slot (#1457, reported by Menthe11) — Reporter on a P1S with generic (non-RFID) PLA saw two different spools rendered in the AMS hover card: the top "Spulen-ID / Im Inventar öffnen" link pointed at an almost-empty black PLA spool that had been in the slot weeks earlier, while the bottom "Zugewiesen" block correctly showed the full spool the user had just assigned via Spoolman. Root cause is two-layered. For non-RFID slots Bambuddy falls back to a deterministic per-slot tag (hash(printer_serial) + ams_id + tray_id, 16 hex chars; see frontend/src/utils/amsHelpers.ts:176). When a user runs Link UI on such a slot, that fallback tag is written to the Spoolman spool's extra.tag — and the existing Link / Assign routes never cleared it from the previous holder when the user re-bound the slot to a different spool. The frontend's hover-card resolver at frontend/src/pages/PrintersPage.tsx:3736 (and the matching sites at :4137 / :4452 for HT and external slots) then preferred that stale tag-link over the user's explicit slot-assignment: linkedSpoolId: (trayTag ? linkedSpools?.[trayTag]?.id : undefined) ?? slotAssignmentForFill?.spoolman_spool_id. So when both layers existed and they disagreed, the stale spool won, and FilamentHoverCard's dedupe at line 377 couldn't collapse the two buttons because the IDs didn't match → two "Im Inventar öffnen" buttons pointing at different spools. The SpoolBuddy AMS page had the identical bug shape in two more spots: getSpoolmanFillForSlot() (the per-slot fill-percentage resolver, line 138) walked tag-link before slot-assignment, so the fill bar reported the old spool's remaining grams instead of the freshly assigned full one; and the slot-action picker's "Linked spool" / "Assigned spool" branches (line 760) showed "Linked spool" whenever a tag-link existed, regardless of whether a (more recent) slot-assignment also existed. Fix has two parts. (1) Frontend precedence swap at all five sites: slot-assignment is the user's most explicit, most recent action — it must outrank the tag-link, which is auto-populated and can be silently stale. With the swap, FilamentHoverCard's existing match-dedupe collapses both buttons into one pointing at the correct spool; SpoolBuddy's fill bar reads from the assigned spool's weight first; and SpoolBuddy's slot-action picker drops the stale "Linked spool" line entirely when a slot-assignment exists. (2) Backend hygiene so the stale state is never written in the first place: a new _clear_stale_tag_links(client, tag, keep_spool_id, log_context) helper in backend/app/api/routes/spoolman_inventory.py enumerates Spoolman spools and PATCHes extra.tag to JSON-empty ('""', the same wire shape unlink_spool already uses so the read-side .strip('"') filter in get_linked_spools skips it) on any spool other than the one being bound that still claims the same tag. Wired into POST /spoolman/inventory/slot-assignments (computes the slot's deterministic fallback tag via the existing get_fallback_spool_tag_for_slot helper in spoolman_tracking.py — newly promoted to a public symbol that mirrors the frontend's getFallbackSpoolTag(serial, amsId, trayId) signature) and POST /spoolman/spools/{id}/link (passes the literal spool_tag being bound — works for both RFID tags and fallback tags). Both are best-effort: per-spool patch failures and Spoolman enumeration failures are logged and skipped, never raised, so the assign/link path never wedges on a Spoolman hiccup. Existing assign-route tests stay green because their fixtures' Spoolman client mock already had get_spools returning [] (or now does — fixture updated in test_spoolman_slot_assignments.py, test_spoolman_slot_concurrency.py, test_spoolman_slot_assignment_mqtt.py, and the link-route test fixture in test_spoolman_api.py). Tests (8 new in backend/tests/unit/test_spoolman_stale_tag_cleanup.py): clears one other-spool while keeping the bound spool and unrelated-tag spool intact; case-insensitive match (the helper uppercases both sides because get_linked_spools already does); empty-tag short-circuits without enumerating spools; keep_spool_id guards against clearing the spool being bound; Spoolman 5xx during enumeration is swallowed and the call returns 0; one per-spool patch failure doesn't abort the rest of the cleanup; the slot-fallback wrapper computes the right tag and clears it; empty serial returns 0 without enumerating. Backend: ruff clean, 581 spoolman tests + 8 new = 589 green. Frontend build clean.
  • AMS drying popover no longer renders off the bottom of the viewport + diagnostic logging for the silent-drying-ignore bug (#1447, reported by kleinweby) — Two distinct bugs in the same report, both shipped in this PR. (1) Popover positioning: reporter on P1S + AMS-HT couldn't see the Start button on the drying popover and worked around it via DevTools to confirm the popover was actually there, just clipped below the fold. Root cause in frontend/src/pages/PrintersPage.tsx:3498 / :4011 (two identical sites — one for the compact AMS row, one for the dual-nozzle layout): the flame-icon onClick computed popover position as a fixed { top: rect.bottom + 4, left: Math.max(8, rect.right - 240) } with no viewport-overflow check. The flame icon sits at the bottom of the AMS info section on the printer card, so on most realistic viewports rect.bottom + 4 + popover_height(~320px) > viewport.height and the popover rendered partially or entirely off-screen. Fix extracts a computePopoverPosition() helper in frontend/src/utils/popoverPosition.ts that defaults to placing the popover below + right-aligned to the trigger (preserving the original visual layout), flips ABOVE the trigger when below would overflow AND above would fit, stays below in the degraded case where neither fits (popover taller than viewport — at least the top is visible and the user can scroll inside), and clamps the left coordinate so a trigger near either viewport edge can't push the popover off-screen horizontally either. Both PrintersPage callsites now go through the helper. (2) Diagnostic logging for the silent-drying-ignore: reporter's support bundle showed the printer receives every ams_filament_drying command (multiple start / stop attempts on ams_id=128, P1S 01.10.00.00 firmware), the printer ACKs each one, but the AMS info field never changes — drying neither starts nor stops on Bambuddy's request, while pressing Start on the printer's touchscreen worked immediately (so the hardware path is healthy and the LAN MQTT channel is delivering). The Bambuddy command JSON matches the format documented as working on H2D, all required fields are present, types match BambuStudio. Diagnosing the silent rejection needs the printer's actual response payload — whether result: "fail" and the specific reason code — but bambu_mqtt.py:918 was only logging the response command name, not the body. The existing extrusion_cali_* / ams_filament_setting debug path at :919-920 was the template; this PR extends it to ams_filament_drying at INFO level specifically (not DEBUG like its siblings) because drying responses are rare — user-initiated only — and INFO ensures the body lands in support bundles by default without needing the user to bump log level first. Paired with a matching outgoing-side INFO log inside send_drying_command that captures the full wire JSON, so the next support bundle has both halves of the conversation. The actual command-side fix can't happen without that data (no guessing — flipping close_power_conflict: true or otherwise mutating a field that matches the documented-working H2D shape could break currently-working installs). When kleinweby retries on this build and re-attaches a bundle, the rejection reason is visible and the command-side fix follows from real data. Tests (8 new in __tests__/utils/popoverPosition.test.ts): below-has-room places below; right-align to trigger; below overflows flips above; degraded case stays below; clamps right-edge and left-edge triggers; respects custom margin and gap. 276 backend service tests + frontend build clean.
  • Stats: Print Activity heatmap buckets prints by local date, not UTC date (#1446, reported and root-caused by needo37) — Reporter on CDT (UTC-5) noticed that prints finished in the local evening were jumping to "tomorrow's" cell on the GitHub-style contribution heatmap on the Stats page. He went through frontend/src/components/PrintCalendar.tsx and identified the root cause: line 30 split the raw ISO string on 'T' to get a YYYY-MM-DD key, which always returns the UTC date — but the cell tooltip (line 161) rendered via toLocaleDateString(), which is local-tz aware. Same data, two renderers, only one was tz-correct. He confirmed with DB query: rows 29 and 30 stored as 2026-05-18 ... UTC were both local May 17 (20:46 CDT and 22:39 CDT), and the Archives → Print Log view formatted them correctly as May 17 via toLocaleString() while the heatmap split them onto May 18 via the raw-ISO shortcut. The component had two more instances of the same shape that I caught while applying the fix: line 152 built the per-cell lookup key via day.toISOString().split('T')[0] (the day Date objects produced by the calendar-generation loop are local-tz constructed via new Date() + setDate, so toISOString() shifted them back to UTC before the lookup — would have re-broken the join even after the bucketing fix), and line 154's "today" highlight comparison used new Date().toISOString().split('T')[0] too (so at e.g. 23:00 CDT the heatmap would have ringed UTC-tomorrow's cell instead of local-today's). Fix adds a localDateKey(input: string | Date): string helper in frontend/src/utils/date.ts that wraps parseUTCDate() and formats via the local-tz getters (getFullYear / getMonth / getDate with two-digit padding), returning a stable comparable YYYY-MM-DD string. PrintCalendar.tsx uses it in all three spots — bucket key for input ISO strings, grid-cell lookup key, and "today" highlight — so the bucketing, the cell join, and the today ring all live on the same local-tz axis as the user's tooltip label. Backend stays UTC (PrintLogEntry.created_at unchanged); bucketing is a presentation concern and the browser already knows the user's tz. The reporter's broader point ("same fix needed anywhere else the frontend buckets timestamps to days") still has stragglers — StatsPage.tsx:55-84 (computeDateRange) builds the dateFrom/dateTo strings for backend stats queries using getUTC* getters everywhere, so a "this week" picked at 23:00 local on Sunday in CDT sends UTC-Monday-based ranges to the backend; that's a separate, deeper bug because it also requires the backend to filter on a tz-shifted UTC range, and Bambuddy has no user-tz setting model today. Punted with a localDateKey helper available for reuse when that work lands. Tests (5 new in __tests__/utils/date.test.ts): keys a local-evening Date to its local date (the bug repro), reproduces the reporter's row-30 case (a moment whose UTC date is "tomorrow" keys to local "today"), pads single-digit month/day, handles null / undefined / empty defensively, and accepts both Date and ISO-string inputs end-to-end via parseUTCDate. 74 date-util tests green; frontend build clean. Tests are written tz-independently — they construct new Date(2026, 4, 17, 22, 0, 0) via the local-time constructor form so they assert correctly regardless of which tz the CI runner happens to be in.
  • Printers: Add Printer no longer hangs the container on P1S (#1445, reported by psybernoid and confirmed by thomassjogren) — Regression introduced in 0.2.4.2 by the fix(printers): refuse to add a printer when the MQTT probe fails change (b51598e). That commit added a pre-insert MQTT probe to POST /printers/ via printer_manager.test_connection() to catch mistyped access codes before persisting an empty card — but the probe had two compounding bugs that bit P1S specifically. First, a fixed await asyncio.sleep(2) checked state.connected exactly once at t=2s: P1S firmware's broker / TLS handshake routinely needs 3–5s to surface a CONNACK on a cold MQTT session (same firmware family that already has the documented "broker stops publishing but TCP stays alive" quirk at bambu_mqtt.py:3181), so the probe falsely rejected a printer that would have connected fine. Second, the finally: client.disconnect() call ran synchronously on the asyncio thread — BambuMQTTClient.disconnect() ends in paho's loop_stop() which join()s the network thread, and if that thread was still mid-TLS-handshake to the slow P1S socket when teardown ran, the join() blocked the asyncio thread for as long as the handshake took to either complete or fail. POST /printers/ therefore wedged, all other HTTP requests queued behind it, and Docker healthcheck timed out → user-visible symptom: "the container hangs." Reporter's workaround (downgrade to 0.2.4.1, add P1S, upgrade back) worked because 0.2.4.1's create-printer route skipped the probe entirely, so the row persisted immediately and the slow handshake happened on a fire-and-forget connect_printer() in the background. Fix swaps the fixed-sleep + sync-disconnect pair for a polling loop with an 8s budget (PROBE_TIMEOUT_SECONDS, configurable as class attributes for tests) that early-returns the moment state.connected flips True — so happy-path connects still finish in ~1–2s and slow brokers get the headroom they need — and moves client.disconnect() to await asyncio.to_thread(client.disconnect) so paho's thread-join can never block the event loop. The new connect_printer from-existing-row flow that runs after a successful probe is unchanged (still fire-and-forget). The empty-card-report-prevention goal of the original probe stays intact: a genuinely wrong access code still results in connected=False after 8s of polling, the 400 with code=printer_connection_failed still fires, the row is still never persisted. Tests (2 new in test_printer_manager.py): test_test_connection_polls_and_returns_early_on_connect simulates the P1S timing — connected=False at probe start, flips True ~500ms in — and asserts the probe early-returns in under 1.5s with success=True (a regression that reverts to the fixed sleep fails this immediately); test_test_connection_disconnect_runs_off_loop mocks a deliberately-slow blocking disconnect (mirrors paho's loop_stop() join semantics) and asserts (a) disconnect ran on a thread other than the asyncio thread, and (b) a concurrent heartbeat coroutine kept ticking while disconnect was blocking the worker thread, proving the event loop wasn't stalled. The existing test_test_connection_failure test was patched to override PROBE_TIMEOUT_SECONDS to 0.4s so the negative path still runs fast under CI. 6 printer-create integration tests still green; ruff clean.
  • Stats: Failure Analysis widget no longer shows "Unknown" for archives classified after the fact (#1444, reported and root-caused by needo37) — Reporter spotted that the Stats page "Top Failure Reasons" widget grouped failed prints as Unknown even after they'd been classified via the Edit Archive modal. He went through the data layer and identified the desync: two failure_reason columns exist — print_archives.failure_reason written by PATCH /archives/{id} and print_log_entries.failure_reason read by the widget (backend/app/services/failure_analysis.py:88). PrintLogEntry.failure_reason gets captured exactly once at print-completion time (backend/app/main.py:3641) by copying archive.failure_reason — and at that moment the archive value is still NULL because the user hasn't picked a reason yet. The Edit Archive modal's PATCH route then writes only to print_archives via a generic setattr loop, never touching the log entry → widget stays stuck on Unknown forever. The reporter confirmed the desync at the DB level (archive.failure_reason = 'Adhesion failure', print_log_entry.failure_reason = NULL). Fix mirrors failure_reason and status from the PATCH payload to the most recent PrintLogEntry for that archive (highest id). Latest-only because archive.failure_reason / status already reflect the latest run's outcome (each reprint clears the archive's reason at main.py:2195 and rewrites it at completion), so the Edit Archive modal is implicitly showing — and editing — the latest run; reprints of an archive that succeeded on the second attempt keep the original failed run's classification intact. Scoped to those two fields only — cost, print_name, printer_id etc are deliberately not mirrored because per-run values legitimately diverge from archive-level ones (e.g. partial-print cost on a failed run differs from the source archive's full-print cost, see _compute_run_filament_grams at main.py:596). Tests (3 new in test_archives_api.py): the bug repro (failure_reason mirrors), the status case (the second field the reporter flagged), and the reprint guard (only the latest of multiple entries gets touched, an earlier entry keeps its prior reason). 55 archives-API tests green; ruff clean.
  • SpoolBuddy: spool ID surfaced everywhere a spool's identity is rendered + Write-Tag page honours Spoolman mode (#1439, reported + partially prototyped by flom89) — Reporter buys filament in bulk and registers every individual spool in Spoolman at intake time (each gets a unique ID + a printed barcode that goes onto the physical roll when it's unboxed). When linking an NFC tag to one of those rolls in SpoolBuddy, the picker showed only material + colour + brand — so for ten identical "Black PLA" rolls every row looked the same. The user had no way to tell which physical spool they were about to bind the tag to; the original #1385 fix had surfaced the ID in Bambuddy's main UI (SpoolFormModal, FilamentHoverCard, the inventory-mode LinkSpoolModal) but the parallel SpoolBuddy components had been missed — they ship as part of the Bambuddy frontend repo under frontend/src/components/spoolbuddy/ and frontend/src/pages/spoolbuddy/, not as a separate codebase. Part 1 — ID surface, seven spots: #<id> in muted small monospace added to LinkSpoolModal.tsx (the link-tag-to-spool picker — the reporter's primary use case), SpoolBuddyWriteTagPage.tsx (write-tag picker — reporter's second screenshot), AssignToAmsModal.tsx header (single-spool context but disambiguating IDs help confirm the right roll was picked), TagDetectedModal.tsx (defined but unmounted today — kept consistent for future use), SpoolInfoCard.tsx (the found-tag panel on the right side of the dashboard — the "main screen" view), InventorySpoolInfoCard.tsx (matching inventory variant), and SpoolBuddyAmsPage.tsx AMS-slot assigned-spool block. All seven placements mirror the #1385 pattern (#<id> with shrink-0 so truncation never hides the ID). Frontend-only — the ID was already on the Spool / InventorySpool API shape (spool.id); these seven files just weren't surfacing it. Part 2 — Spoolman-mode parity on the Write-Tag page: reporter then surfaced that the same page hardcoded api.getSpools(false) regardless of inventory backend — so users in Spoolman mode (whose authoritative inventory is at Spoolman, not the internal table) saw spools they never created, and a successful tag write would bind the NFC tag to the wrong backend (the backend /spoolbuddy/nfc/write-tag route is mode-aware via _get_spoolman_client_or_none, but the frontend was driving it with internal-mode IDs that don't exist on the Spoolman side). Fix follows the wrapper pattern InventoryPage uses (InventoryPageRouter at :445): page detects spoolmanMode from a getSpoolmanSettings query at the top and threads it through, with enabled: spoolmanModeReady gating the spool fetch until settings load so we don't burn a wrong-backend request during the initial render. Every API call in the page now branches on spoolmanMode — 6 sites: the main spool list, the NewSpoolTouchForm's autocomplete spool list, the untag flow (linkTagToSpool vs linkTagToSpoolmanSpool — the Spoolman variant doesn't accept data_origin since Spoolman manages that), the K-profile save (saveSpoolKProfiles vs saveSpoolmanKProfiles), single-spool create (createSpool vs createSpoolmanInventorySpool), and bulk create (bulkCreateSpools vs bulkCreateSpoolmanInventorySpools — the Spoolman variant returns a SpoolmanBulkCreateResult envelope vs raw array, handled with a duck-typed 'created' in result check that mirrors SpoolFormModal's existing pattern). Same shape rule as [[feedback_sqlite_and_postgres_upfront]] / [[feedback_inventory_modes_parity]]: both modes ship in the same drop, no spoolmanMode ? undefined : ... UI gates. Tests: 3 new in SpoolBuddyWriteTagPage.test.tsx — the ID-visibility regression with two identical PLA-Red rolls IDs 42 / 43 (a future refactor that drops the ID span breaks it), plus two parity regressions (reads from internal inventory when Spoolman mode is OFF and reads from Spoolman when Spoolman mode is ON — the latter asserts getSpools is NOT called when the user is in Spoolman mode, so re-hardcoding the internal endpoint breaks CI immediately). 11 WriteTagPage tests + 51 other SpoolBuddy component tests green (62 total); frontend build clean.
  • FTP: P2S upload truncates / 426 "Failure reading network stream" on Python 3.13 (#1401, reported and root-caused by iitazz) — Reporter on a P2S running firmware 01.02.00.00 saw every Bambuddy-initiated print fail with the printer's on-screen "unable to parse 3mf file" error ~30 s in; downloading the file back off the printer's SD card confirmed it was truncated at exactly 7 × 64 KB (clean chunk-boundary cut). Initial #1417 follow-up tightened our 426 handling so we'd surface upload failures instead of silently dispatching a print of a partial 3MF — but that only stopped Bambuddy from hiding the problem; the actual upload still failed. The reporter then dug into it with Gemini and identified the real cause: Python 3.13's default ssl.create_default_context() negotiates TLS 1.3 when both peers support it, but the printer's vsFTPd build implements session reuse on the FTPS data channel against an old OpenSSL that doesn't tolerate TLS 1.3's asynchronous session-ticket model. The control-channel handshake completes, the data channel tries to resume the session, the resumption races, the data channel gets torn down mid-stream — first ~448 KB of bytes already in the TCP buffer land on the SD card, the rest never make it, printer's vsFTPd replies 426 instead of 226. Fix caps the SSL context's maximum_version to TLS 1.2 so session resumption is synchronous and the upload completes normally. Implementation follows the pattern just established by camera_profiles.py in the #1395 follow-up: a new backend/app/services/ftp_profiles.py module with an FTPProfile frozen dataclass (one field today, cap_tls_v1_2: bool = False) and a per-model registry. Default profile keeps the historical TLS-1.3 negotiation; P2S (display name + internal SSDP code N7) overrides with cap_tls_v1_2=True. ImplicitFTP_TLS.__init__ gains a matching cap_tls_v1_2 kwarg; BambuFTPClient.connect() looks up the profile and threads the flag through. Deliberately scoped to P2S only — X1C / P1S / H2D installs that work today stay on the negotiated TLS 1.3; flipping a future model to the capped path is a one-line entry in _PROFILES when a new reporter surfaces the same symptom. Considered but rejected the reporter's second proposed change (revert manual transfercmd + sendall back to storbinary) — the stated rationale ("raw sendall breaks OpenSSL 3.x framing") is incorrect (CPython's storbinary itself uses sendall internally; the actual socket-level behaviour is identical), the move to manual transfercmd was deliberate to dodge A1 hanging in storbinary's synchronous voidresp(), and the #1417 SIZE-check escape for the "data is intact on the SD card despite the 426" race lives in the manual-transfer path — a switch to storbinary would lose that protection. Tests: 9 new in test_ftp_profiles.py (default profile doesn't cap; unknown / empty model falls back; P2S display name and N7 SSDP code both resolve to capped; lookup is case-insensitive; X1C / H2D / P1S / A1 stay uncapped; dataclass is frozen; integration test pins the wiringImplicitFTP_TLS(cap_tls_v1_2=True) actually sets ssl_context.maximum_version == TLSVersion.TLSv1_2, guards against a future refactor that drops the profile→context wiring while keeping the registry looking correct). 87 existing test_bambu_ftp.py tests still green; ruff clean.
  • Library 3D preview: complex multi-part 3MFs no longer freeze the page (#1412, reported by anthonyma94) — Reporter opened the 3D preview on a multi-color parted MakerWorld statue ("Mecha Mewtwo No AMS Multi Color Parted Statue") and the whole Bambuddy UI locked up — modal close button unresponsive, had to kill the tab. Root cause was in frontend/src/components/ModelViewer.tsx: the 3MF parse runs entirely on the browser main thread (JSZip extract + DOMParser + getElementsByTagName('vertex') / ('triangle') iteration + mergeGeometries), with no yield points between iterations. Bambu Studio's external-component shape (<component p:path="..."/> per part) compounds this — each component triggers another async file extract + DOM parse + vertex/triangle loop, all chained without surrendering control to the event loop between phases. For trivial models (the towel hook and Bambu scraper the reporter cited as working) the total wall-clock is short enough that the freeze isn't visible; for parted statues with dozens of components and high-poly meshes, the main thread is pegged for tens of seconds → browser shows "page unresponsive" and the close button can't fire. Stopgap that shipped here adds explicit nextTick() yields (await new Promise(r => setTimeout(r, 0))) at four hot spots: every 20 000 vertex iterations, every 20 000 triangle iterations, once per top-level <object> iteration, and once per <component> iteration. Parse wall-clock is unchanged — these yields don't make parsing faster, they just surrender the main thread back to the browser between batches so the modal can be closed, the page can scroll, and the loading spinner can actually render. Constants live next to the helper at the top of the file with a comment justifying the picked period (~5–10 ms of work per batch — fine-grained enough to keep frames flowing, coarse enough not to drown the loop in setTimeout dispatch overhead). The proper fix for this — moving 3MF parse + geometry build into a Web Worker so the main thread is never touched at all — is a tracked follow-up; this stopgap unblocks Anthony's reproduction case today without the worker refactor risk. The earlier close as invalid was a misdiagnosis (initial reading was that 3D preview only works on sliced files, which the reporter correctly disproved with a separate MakerWorld URL); reopened, fixed, lesson noted. Tests: 21 existing ModelViewerModal.test.tsx tests stay green — the yields are in parseMeshFromDoc and parse3MF which the tests mock around, and the nextTick helper has no observable side effects beyond timing. Frontend build clean.
  • Archives: timelapse auto-attach now works for VP-queue / dispatch prints (#1403 follow-up, reported by pwostran) — Bambuddy uses a snapshot-diff strategy to pick the right MP4 off the printer's SD card after a print (Bambu printers in LAN-only mode don't sync NTP, so file mtimes are unreliable — _scan_for_timelapse_with_retries snapshots existing video filenames at print start and looks for any NEW filename at completion). The baseline-capture call was inline at the bottom of on_print_start's new-archive branch only — the expected-archive branch (which queue / VP-dispatched / reprinted jobs take, anything registered via register_expected_print) exited at its own return without ever snapshotting. So queue prints had _timelapse_baselines[printer_id] unset; the completion-time scan fell into its "take baseline now" fallback that snapshots the SD card after the new MP4 has already landed → the new file sits in the "baseline" set → no diff ever matches → auto-attach silently does nothing. The reporter's bambuddy-support-20260518-185935.zip shows the failure verbatim: Using expected archive 3 for print (skipping duplicate) at 18:41:10, Timelapse was active during print, scheduling auto-scan for archive 3 at 18:58:55, and [TIMELAPSE] Archive 3 has no printer, aborting (a separate bug already fixed by the printer_id-assignment commit in this same train) — and grep -i baseline across both his bundles returns zero hits, confirming the snapshot never ran. Fix extracts the inline baseline-capture into _capture_timelapse_baseline_at_start(printer, printer_id, logger) and calls it from BOTH branches of on_print_start (mirroring the existing site in the new-archive branch with a matching call just before the expected-archive branch's return). Helper is best-effort with a try / except Exception wrapping _list_timelapse_videos, so a transient FTP failure at print-start logs [TIMELAPSE] Failed to capture baseline at print start: … and the print proceeds — the completion-time fallback still kicks in (with its known limitation), behaviour matching what the new-archive branch had all along. Tests: new test_expected_archive_path_captures_timelapse_baseline in the existing test_print_start_assigns_printer_id_to_vp_archive.py patches _list_timelapse_videos to return two pre-existing videos, runs on_print_start through the expected-archive branch, and asserts _timelapse_baselines[1] == {"earlier_print_a.mp4", "earlier_print_b.mp4"} — a future refactor that removes the call from one of the branches now fails CI. The existing 2 regressions (test_expected_archive_path_assigns_printer_id_when_unset, test_expected_archive_path_preserves_existing_printer_id) plus 50 adjacent expected-archive / layer-timelapse / archive-filtering tests still green. The fixture clearing _expected_prints etc also clears _timelapse_baselines now so test isolation holds.

Don't miss a new bambuddy release

NewReleases is sending notifications on new releases.