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$'tofrontend/src/utils/currency.tsnext to MXN (Americas dollar-prefix grouping);getCurrencySymbol('BZD')returns'BZ$'and the SUPPORTED_CURRENCIES list now has 30 entries. Unit test added infrontend/src/__tests__/utils/currency.test.tscovering 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.3mfsliced via the X1C bundle (POST /slicewithbundle=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_modeloverridden toBambu Lab X1 Carbon,printable_areato 256×256 (X1C bed, not H2D's 350×320),printable_height250 (vs 325),bed_exclude_areapopulated with X1C's 18×28 corner zone,nozzle_diametersingle 0.4 (vs H2D's dual0.4,0.4), and the full X1Cmachine_start_gcodesequence 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!printerMismatchfrom the SliceModalisReadypredicate 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, thesource_printer_modelfield on the/library/files/{id}/platesand/archives/{id}/platesresponses had zero consumers; theextract_source_printer_model_from_3mfhelper inthreemf_tools.py(which opened the 3MF zip and readMetadata/project_settings.configon every plate request) had zero callers. Removed both response keys, both backend extractions, boththreemf_toolsimports, the helper itself, its 6 unit tests, thesource_printer_modelfield fromfrontend/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 EnglishdefaultValue: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. Theslice.printerMismatchkey 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.15to clear CVE-2026-45409 (ReDoS inidna.encode()with crafted Unicode payloads, e.g."٠" * Nor"・" * N + "漢") — Transitive dep pulled in by anyio / httpx / requests / yarl; not directly pinned, which is why it lingered at 3.13. Added an explicitidna>=3.15floor inrequirements.txtbetween Authentication and HTTP-client blocks with a comment explaining why it's pinned (so a future downstream loosening doesn't silently downgrade us). Verified viapip-auditclean 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:184auto-generates secrets viasecrets.token_urlsafe(64)(~86 chars of entropy, far above any sane minimum) and the file-loaded path at:177rejects secrets shorter than 32 chars. Added a permanent--ignore-vuln CVE-2025-45768to.github/workflows/security.ymlwith 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-4539for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified:pip-audit --ignore-vuln CVE-2025-45768alone reports clean).
Fixed
- Scheduler: queue items with
force_color_matchfilament 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 withfilament_overrides: [{slot_id: 1, type: "PLA", color: "#CBC6B8", force_color_match: true}]andams_mapping: null, expecting Bambuddy to translate the override into a slot mapping at dispatch time. Instead the scheduler dispatched withams_mapping: nulland 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./filamentonly 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 byslot_id(highestused_gramswins 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./filamentXPath 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_requirementscan still return None (3MF missingslice_info.configentirely, IO failure during ZIP extraction, etc). New_build_override_direct_mapping(force_overrides, status)helper kicks in at exactly that moment whenforce_color_matchoverrides are present — builds the requirement list directly from the overrides (slot_id,type,color, emptytray_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:590already 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_mappingruns the exact match is guaranteed in the loaded set and the cascade'sexact_matchbranch wins (colour normalisation is identical on both sides —tray_color.replace("#", "").lower()[:6]). Pref-only overrides (withoutforce_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 viaforce_color_match: true. Backwards-compat triple-checked: legacy 3MF format unchanged (preserved fallback path);plate_id != Nonebranch untouched (entire fix is inside theelseofif plate_id is not None);filament_overrides=None/[]/ no-force-entries all preserve the existingreturn Nonepath; malformed JSON infilament_overridesis caught by the existing try/except, logged, and still returns None. Tests (22 new across two files; all pass onpytest -n 30):backend/tests/unit/services/test_filament_requirements.py— 4 tests covering theplate_id=Nonemodern-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 acrossTestBuildOverrideDirectMapping(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_idxis cleared) andTestComputeAmsMappingFallback(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.tagon 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 localspoolman_slot_assignmentstable 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'sextra.tagfield 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 viaclient.find_spool_by_tag(spool_tag)— a single tag-lookup against Spoolman'sextra.tag. For tag-less spools that returns None and the tracker silently skipped the slot. The tracker never consulted the localspoolman_slot_assignmentstable that has the answer (verified:grep -n SpoolmanSlotAssignment backend/app/services/spoolman_tracking.pyreturned zero hits before this fix). So Bambu Lab RFID users got correct weight reporting (theirextra.tagis auto-populated by the AMS-synccreate_spoolpath atbackend/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 existingclient.find_spool_by_tag(spool_tag)(RFID and any RFID-equivalentextra.tagvalue), stage 2 is the new_resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id)helper that queries theSpoolmanSlotAssignmenttable 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_slothelper — 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.tagis the authoritative binding, even if the slot-assignment table happens to point at a different spool (legacy state). The resulting[SPOOLMAN] … via tagvs… via slot-assignmentsuffix in the success log makes it obvious which path resolved each slot, which support bundles will use to confirm the fix is live.printer_idthreaded through the three callers (_report_partial_usageG-code path,_report_partial_usagelinear path,report_usage) — they all already hadprinter_idin scope. Crucially,extra.tagis 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'sextra.tagis reserved for hardware RFID identifiers. Tests: 5 new inbackend/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). Newpatch_async_sessionfixture routes the tracker's module-levelasync_sessionto the test engine so the in-testSpoolmanSlotAssignmentinsert is visible to the lookup. Postgres compatibility: verified — the lookup uses a plainselect(...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; seefrontend/src/utils/amsHelpers.ts:176). When a user runs Link UI on such a slot, that fallback tag is written to the Spoolman spool'sextra.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 atfrontend/src/pages/PrintersPage.tsx:3736(and the matching sites at:4137/:4452for 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 inbackend/app/api/routes/spoolman_inventory.pyenumerates Spoolman spools and PATCHesextra.tagto JSON-empty ('""', the same wire shapeunlink_spoolalready uses so the read-side.strip('"')filter inget_linked_spoolsskips it) on any spool other than the one being bound that still claims the same tag. Wired intoPOST /spoolman/inventory/slot-assignments(computes the slot's deterministic fallback tag via the existingget_fallback_spool_tag_for_slothelper inspoolman_tracking.py— newly promoted to a public symbol that mirrors the frontend'sgetFallbackSpoolTag(serial, amsId, trayId)signature) andPOST /spoolman/spools/{id}/link(passes the literalspool_tagbeing 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 hadget_spoolsreturning[](or now does — fixture updated intest_spoolman_slot_assignments.py,test_spoolman_slot_concurrency.py,test_spoolman_slot_assignment_mqtt.py, and the link-route test fixture intest_spoolman_api.py). Tests (8 new inbackend/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 becauseget_linked_spoolsalready does); empty-tag short-circuits without enumerating spools;keep_spool_idguards 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 viewportsrect.bottom + 4 + popover_height(~320px) > viewport.heightand the popover rendered partially or entirely off-screen. Fix extracts acomputePopoverPosition()helper infrontend/src/utils/popoverPosition.tsthat 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 everyams_filament_dryingcommand (multiple start / stop attempts onams_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 — whetherresult: "fail"and the specificreasoncode — butbambu_mqtt.py:918was only logging the response command name, not the body. The existingextrusion_cali_*/ams_filament_settingdebug path at:919-920was the template; this PR extends it toams_filament_dryingat 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 insidesend_drying_commandthat 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 — flippingclose_power_conflict: trueor 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.tsxand 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 viatoLocaleDateString(), which is local-tz aware. Same data, two renderers, only one was tz-correct. He confirmed with DB query: rows 29 and 30 stored as2026-05-18 ... UTCwere both localMay 17(20:46 CDT and 22:39 CDT), and the Archives → Print Log view formatted them correctly as May 17 viatoLocaleString()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 viaday.toISOString().split('T')[0](thedayDate objects produced by the calendar-generation loop are local-tz constructed vianew Date()+setDate, sotoISOString()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 usednew 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 alocalDateKey(input: string | Date): stringhelper infrontend/src/utils/date.tsthat wrapsparseUTCDate()and formats via the local-tz getters (getFullYear/getMonth/getDatewith 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_atunchanged); 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 usinggetUTC*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 alocalDateKeyhelper 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 viaparseUTCDate. 74 date-util tests green; frontend build clean. Tests are written tz-independently — they constructnew 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 failschange (b51598e). That commit added a pre-insert MQTT probe toPOST /printers/viaprinter_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 fixedawait asyncio.sleep(2)checkedstate.connectedexactly 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 atbambu_mqtt.py:3181), so the probe falsely rejected a printer that would have connected fine. Second, thefinally: client.disconnect()call ran synchronously on the asyncio thread —BambuMQTTClient.disconnect()ends in paho'sloop_stop()whichjoin()s the network thread, and if that thread was still mid-TLS-handshake to the slow P1S socket when teardown ran, thejoin()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-forgetconnect_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 momentstate.connectedflips True — so happy-path connects still finish in ~1–2s and slow brokers get the headroom they need — and movesclient.disconnect()toawait asyncio.to_thread(client.disconnect)so paho's thread-join can never block the event loop. The newconnect_printerfrom-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 inconnected=Falseafter 8s of polling, the 400 withcode=printer_connection_failedstill fires, the row is still never persisted. Tests (2 new intest_printer_manager.py):test_test_connection_polls_and_returns_early_on_connectsimulates the P1S timing —connected=Falseat probe start, flips True ~500ms in — and asserts the probe early-returns in under 1.5s withsuccess=True(a regression that reverts to the fixed sleep fails this immediately);test_test_connection_disconnect_runs_off_loopmocks a deliberately-slow blocking disconnect (mirrors paho'sloop_stop()join semantics) and asserts (a)disconnectran 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 existingtest_test_connection_failuretest was patched to overridePROBE_TIMEOUT_SECONDSto 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
Unknowneven after they'd been classified via the Edit Archive modal. He went through the data layer and identified the desync: twofailure_reasoncolumns exist —print_archives.failure_reasonwritten byPATCH /archives/{id}andprint_log_entries.failure_reasonread by the widget (backend/app/services/failure_analysis.py:88).PrintLogEntry.failure_reasongets captured exactly once at print-completion time (backend/app/main.py:3641) by copyingarchive.failure_reason— and at that moment the archive value is stillNULLbecause the user hasn't picked a reason yet. The Edit Archive modal's PATCH route then writes only toprint_archivesvia a genericsetattrloop, never touching the log entry → widget stays stuck onUnknownforever. The reporter confirmed the desync at the DB level (archive.failure_reason = 'Adhesion failure',print_log_entry.failure_reason = NULL). Fix mirrorsfailure_reasonandstatusfrom the PATCH payload to the most recentPrintLogEntryfor that archive (highestid). Latest-only becausearchive.failure_reason/statusalready reflect the latest run's outcome (each reprint clears the archive's reason atmain.py:2195and 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_idetc 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_gramsatmain.py:596). Tests (3 new intest_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/andfrontend/src/pages/spoolbuddy/, not as a separate codebase. Part 1 — ID surface, seven spots:#<id>in muted small monospace added toLinkSpoolModal.tsx(the link-tag-to-spool picker — the reporter's primary use case),SpoolBuddyWriteTagPage.tsx(write-tag picker — reporter's second screenshot),AssignToAmsModal.tsxheader (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), andSpoolBuddyAmsPage.tsxAMS-slot assigned-spool block. All seven placements mirror the #1385 pattern (#<id>withshrink-0so 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 hardcodedapi.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-tagroute 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 (InventoryPageRouterat:445): page detectsspoolmanModefrom agetSpoolmanSettingsquery at the top and threads it through, withenabled: spoolmanModeReadygating 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 onspoolmanMode— 6 sites: the main spool list, the NewSpoolTouchForm's autocomplete spool list, the untag flow (linkTagToSpoolvslinkTagToSpoolmanSpool— the Spoolman variant doesn't acceptdata_originsince Spoolman manages that), the K-profile save (saveSpoolKProfilesvssaveSpoolmanKProfiles), single-spool create (createSpoolvscreateSpoolmanInventorySpool), and bulk create (bulkCreateSpoolsvsbulkCreateSpoolmanInventorySpools— the Spoolman variant returns aSpoolmanBulkCreateResultenvelope vs raw array, handled with a duck-typed'created' in resultcheck that mirrorsSpoolFormModal's existing pattern). Same shape rule as [[feedback_sqlite_and_postgres_upfront]] / [[feedback_inventory_modes_parity]]: both modes ship in the same drop, nospoolmanMode ? undefined : ...UI gates. Tests: 3 new inSpoolBuddyWriteTagPage.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 OFFandreads from Spoolman when Spoolman mode is ON— the latter assertsgetSpoolsis 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'smaximum_versionto TLS 1.2 so session resumption is synchronous and the upload completes normally. Implementation follows the pattern just established bycamera_profiles.pyin the #1395 follow-up: a newbackend/app/services/ftp_profiles.pymodule with anFTPProfilefrozen 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 withcap_tls_v1_2=True.ImplicitFTP_TLS.__init__gains a matchingcap_tls_v1_2kwarg;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_PROFILESwhen a new reporter surfaces the same symptom. Considered but rejected the reporter's second proposed change (revert manualtransfercmd+sendallback tostorbinary) — the stated rationale ("raw sendall breaks OpenSSL 3.x framing") is incorrect (CPython'sstorbinaryitself usessendallinternally; the actual socket-level behaviour is identical), the move to manualtransfercmdwas deliberate to dodge A1 hanging instorbinary's synchronousvoidresp(), 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 tostorbinarywould lose that protection. Tests: 9 new intest_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 wiring —ImplicitFTP_TLS(cap_tls_v1_2=True)actually setsssl_context.maximum_version == TLSVersion.TLSv1_2, guards against a future refactor that drops the profile→context wiring while keeping the registry looking correct). 87 existingtest_bambu_ftp.pytests 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 explicitnextTick()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 asinvalidwas 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 existingModelViewerModal.test.tsxtests stay green — the yields are inparseMeshFromDocandparse3MFwhich the tests mock around, and thenextTickhelper 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_retriessnapshots existing video filenames at print start and looks for any NEW filename at completion). The baseline-capture call was inline at the bottom ofon_print_start's new-archive branch only — the expected-archive branch (which queue / VP-dispatched / reprinted jobs take, anything registered viaregister_expected_print) exited at its ownreturnwithout 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'sbambuddy-support-20260518-185935.zipshows the failure verbatim:Using expected archive 3 for print (skipping duplicate)at18:41:10,Timelapse was active during print, scheduling auto-scan for archive 3at18: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) — andgrep -i baselineacross 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 ofon_print_start(mirroring the existing site in the new-archive branch with a matching call just before the expected-archive branch'sreturn). Helper is best-effort with atry / except Exceptionwrapping_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: newtest_expected_archive_path_captures_timelapse_baselinein the existingtest_print_start_assigns_printer_id_to_vp_archive.pypatches_list_timelapse_videosto return two pre-existing videos, runson_print_startthrough 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_printsetc also clears_timelapse_baselinesnow so test isolation holds.