Note
This is a daily beta build (2026-05-22). 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
- System Health — log scanner that surfaces self-fixable issues before they become support tickets — Complements the active Connection Diagnostic with a passive check: it scans Bambuddy's recent app log against a curated catalog of known failure signatures and reports what it finds. The catalog (
backend/app/services/log_health.py) is a deliberate allowlist — only known-bad, actionable patterns match, so a healthy install reports nothing and noisy benign churn (the occasional MQTT reconnect after a Wi-Fi blip) is gated behind a per-signaturemin_countthreshold. Six seed signatures cover the recurring "layer 8" causes from the closed-issue triage: rejected access code, FTPS :990 timeout, FTPS TLS handshake failure, flapping MQTT connection, unreachable camera (RTSPS :322), and SQLitedatabase is lockedcontention. Each finding is deduped (occurred N×, last seen …), classified as you can fix this / environment / please report this, and carries a deep-link to the troubleshooting wiki; sample log lines are sanitized (IPs, serials, access codes redacted) before they leave the process. Exposed viaGET /system/healthand surfaced on two surfaces that share oneSystemHealthPanelcomponent: a System Health section on the System page (on-demand re-scan), and inline in the bug reporter when the form opens — so a setup mistake gets self-resolved instead of becoming a GitHub issue. The Add-Printer and Edit-Printer dialogs also gained a setup-time pre-flight: saving now runs the connection diagnostic and, if a check fails, warns with a "save anyway" escape hatch instead of silently saving a printer that will immediately show offline. Log-reading and redaction primitives were extracted fromroutes/support.pyinto a sharedbackend/app/services/log_reader.py(behaviour-preserving). 13 backend tests (test_log_health.py,test_system_api.py) and 8 frontend tests (SystemHealthPanel,BugReportBubble,AddPrinterPreflight,EditPrinterPreflight); all strings translated across the 9 locales. Backend ruff clean, full unit suite green, frontend build clean, i18n parity green. - Event-loop stall watchdog — makes a frozen backend self-diagnose (#1486 groundwork) — Several "container hangs after adding a printer" reports share a signature that leaves nothing to act on: the HTTP server goes silent,
/healthhangs, the process may stop responding to SIGTERM — and the logs just stop mid-stream with no traceback, because a frozen asyncio event loop cannot log anything. Newbackend/app/services/loop_watchdog.pycloses that blind spot: an async heartbeat re-armsfaulthandler.dump_traceback_later()every 10s, always 30s ahead. While the loop ticks, the timer is cancelled and re-armed before it can fire; if the loop stalls, the heartbeat can't re-arm and faulthandler's dedicated C-level timer thread — which runs independently of the frozen loop — dumps every thread's stack to stderr. The blocked frame then appears indocker compose logs, turning an un-diagnosable freeze into a one-command capture. Started in the app lifespan after migrations, stopped cleanly on shutdown; 30s threshold is well above any legitimate on-loop operation, so a trip always means a real bug. 5 unit tests intest_loop_watchdog.py(arms the timer, idempotent start, stop disarms + cancels, heartbeat interval below the threshold, survives a re-arm error). Backend ruff clean; full app lifespan verified via the integration suite. - Slicer: process & filament profiles filtered by the selected printer (#1325, requested by IndividualGhost1905) — In the server-side Slice dialog, picking a printer profile now filters the Process and Filament dropdowns to presets compatible with that printer; presets that resolve to a different Bambu model drop into a trailing "Other printers" group instead of cluttering the main list. Matching uses the slicer's own
compatible_printerslist for imported (local) presets, and falls back to theBBL <model>name suffix for cloud and standard presets, so all three tiers are covered. Compatibility-unknown presets (custom or untagged) are never hidden. Defaults follow suit — the pre-picked process and per-slot filament now prefer a printer-compatible preset, and switching the printer re-picks any selection left incompatible. The printer and process dropdowns also default to the preset names embedded in the source 3MF'sproject_settings.configwhen those presets are available, instead of always taking the first listed preset. Newfrontend/src/utils/slicerPrinterMatch.ts(11 unit tests) andextract_embedded_presets_from_3mf(5 unit tests);UnifiedPresetnow carriescompatible_printers, exposed for the local tier (backend/app/api/routes/slicer_presets.py); the plates endpoints returnembedded_printer/embedded_process. Parity green, build clean. - Spanish (es) translation (#1243, requested by MiguelAngelLV) — Bambuddy now ships a full European Spanish locale. New
frontend/src/i18n/locales/es.tstranslates all 4899 keys with placeholders, plural forms, and inline markup preserved; registered infrontend/src/i18n/index.tsand selectable as "Español" in the language picker. The parity checker auto-discovers the file —frontend/scripts/check-i18n-parity.mjsgained anES_COGNATESallow-list for genuine Spanish cognates and brand/format tokens. Brings the supported-language count to 9 (en / de / es / fr / it / ja / pt-BR / zh-CN / zh-TW). Parity green, frontend build clean. - 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. - Connection Diagnostic — self-service triage for "printer won't connect / won't print" — A triage review of recently-closed issues found roughly a third were user-side setup errors (printer not in LAN developer mode, blocked ports, Docker bridge networking, wrong access code, printer on a different subnet), each costing a multi-round-trip "enable debug logging → build a support bundle → upload it" exchange. A new diagnostic (
backend/app/services/printer_diagnostic.py) runs those checks automatically: TCP reachability of MQTT 8883 / FTPS 990 / RTSPS 322, LAN developer mode, Docker network mode, printer/host subnet match, and MQTT credential class — each returning a pass / fail / warn / skip status with a localized plain-language fix. Exposed viaGET /printers/{id}/diagnostic(saved printer) andPOST /printers/diagnostic(pre-save Add-Printer flow), and surfaced as a one-click "Run diagnostic" from the printer card actions menu (plus a quick button on the card when a printer is offline), the Add-Printer dialog, and a new Connection Diagnostic section on the System page. The in-app bug reporter scans configured printers when the report form opens and always shows the result — a healthy confirmation when nothing's wrong, or the detected problem and its fix inline — so setup mistakes get self-resolved instead of becoming GitHub issues. The GitHubconfig.ymltroubleshooting link was repointed from the wiki source repo to the rendered troubleshooting page. Backend service unit tests (15) and frontend modal tests (3) added; all diagnostic strings translated across the 8 locales. Backend ruff clean, frontend build clean, i18n parity green.
Changed
- Virtual printer: setup diagnostic + one-click slicer-certificate export — Two recurring virtual-printer support pains, addressed on the Virtual Printers settings page. (1) Setup check — a new stethoscope action on each VP card runs
GET /virtual-printers/{id}/diagnosticand shows a pass/fail/warn/skip checklist: VP enabled, services running, bind interface still exists, access code set, target printer (proxy mode), and — decisively — a live TCP probe of the FTP/MQTT/discovery ports on the bind IP. The manager swallows per-service start errors (run_with_logging), so a service object can exist while nothing is actually listening; probing the bind IP from outside is the only reliable signal, and it catches the common "VP doesn't show up in the slicer" bind-IP-conflict and stale-interface cases. Newbackend/app/services/virtual_printer/diagnostic.py+VPDiagnosticResultschema +VirtualPrinterDiagnosticModal.tsx. (2) Slicer certificate — virtual printers present a TLS cert signed by a shared CA the slicer must trust; until now users had todocker execin andcat bbl_ca.crtto get it. A new "Slicer certificate" row on the Virtual Printers settings card (alongside the Archive name source toggle) offers Copy and Download (bambuddy-virtual-printer-ca.crt) plus the CA's SHA-256 fingerprint, served byGET /virtual-printers/ca-certificate— only the public certificate, never the CA private key. The CA is generated on demand so the button works before the first VP is enabled. Copy uses a non-secure-context fallback (Bambuddy is usually on plain-HTTP LAN), extracted into a sharedutils/clipboard.ts. 9 backend diagnostic/CA unit tests + 4 route integration tests + 6 frontend tests (diagnostic modal, clipboard helpers); allvpDiagnostic.*/virtualPrinter.caCert.*strings translated across the 9 locales. Backend ruff clean, frontend build clean, i18n parity green. - Bug-report panel: connection diagnostic no longer overflows on multi-printer setups — The "Report a Bug" panel scans every configured printer on open and surfaces connection problems inline so users can self-fix before filing. The first cut rendered a full ~6-row checklist for each problem printer stacked vertically; a user with many printers all reporting issues pushed the description box, screenshot uploader and Submit button far below the fold in the
max-w-md/max-h-[80vh]panel. The diagnostic section is now a compact summary — one line ("N of M printers have connection issues") followed by the affected printers as collapsed rows (healthy printers count toward M but render no detail). Each row expands on demand to that printer's full checklist via the sharedCollapsiblewidget; when exactly one printer has problems the row is auto-expanded since that's the case where inline detail is wanted with no extra click. The panel now stays a fixed ~3 lines plus one row per affected printer regardless of fleet size, keeping the report form reachable. Healthy-fleet confirmation line is unchanged. NewbugReport.diagnosticSummarykey (with{{problems}}/{{total}}) replaces the staticdiagnosticHeading;diagnosticIntroreworded to be printer-count-neutral and point at the expand affordance — both translated across all 9 locales. 2 new tests inBugReportBubble.test.tsx(multiple problems stay collapsed and expand on click; a single problem auto-expands); 11 tests green; frontend build clean; i18n parity holds at 4903 keys × 9 locales. - Color Catalog sync now identifies itself as Bambuddy to filamentcolors.xyz — The FilamentColors.xyz sync client in
inventory.pycreated itshttpx.AsyncClientwith noUser-Agent, so it leaked httpx's defaultpython-httpx/x.ystring — the only outbound client that did (bambu_cloud,makerworld,firmware_checkall send the honestBambuddy/1.0 (+https://github.com/maziggy/bambuddy)). It now sends the same honest UA, consistent with the rest of the codebase. Surfaced while investigating #1456 (a Cloudflare403on the sync that turned out to be the reporter's network/IP reputation, not Bambuddy — the UA leak was a separate inconsistency found in passing, and this change does not by itself resolve a Cloudflare IP block). - Filament inventory: grouped rows now show group totals (#1368, requested by a user) — With "Group similar" enabled, the collapsed group row showed the values of a single member (the first spool) — so a group of five 1 kg spools displayed "1000 g" instead of the 5 kg it actually held. The group header now aggregates across all members: the table view's Label, Net, Gross, Used and Remaining columns and the grid card's weight figure show group totals, while identity columns (Material, Brand, Colour) and the Cost/kg rate stay per-spool-correct. Per-spool-only fields with no meaningful total (dates, location, note, tag ID) keep showing the representative member's value; the expanded individual rows are unchanged. New
aggregateGroupSpoolhelper infrontend/src/utils/inventoryGrouping.tswith 4 unit tests. Frontend-only — all data was already in the spool list. — 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-boundTrent900.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
- Archive filament colour now reflects the assigned inventory spool, not the slicer's 3MF (#1494, reported by IndividualGhost1905) — A user added a
#000000black filament to the built-in inventory, assigned it to the printer, and printed from the desktop slicer; the print, AMS and inventory all showed it as black and the correct spool's weight decremented — but the resulting archive (and the Color Distribution graph) showed#161616. Root cause is two independent colour sources: an archive'sfilament_coloris parsed verbatim from the print job's 3MF (archive.py_extract_filament_inforeadsfilament_colourfromproject_settings.config), which carries the slicer's filament-slot colour — a value the user picks separately from the exact hex they curate on the Bambuddy inventory spool. The two are "close but not equal" (slicer near-black#161616vs inventory#000000), which is exactly the "always a similar colour, never an unrelated one" pattern the report describes. Fix: once usage tracking has resolved the print's filament slots to inventory spools, the spool colours are authoritative —_track_from_3mf(built-in inventory) andreport_usage(Spoolman mode) now overwrite the archive'sfilament_colorwith the slot-ordered, de-duplicated colours of the matched spools. The rewrite is all-or-nothing: it only applies when every used slot resolved to a spool that carries a colour, so a partially-mapped multi-colour print never silently loses the unmatched slots' colours (the 3MF value is kept). Shipped for both inventory modes in the same drop — built-in spools readSpool.rgba, Spoolman spools read the spool'sfilament.color_hex(fetched for tag-less slot-assignment matches). New helpers_spool_color_to_hex/_archive_colors_from_spoolsinusage_tracker.py, reused byspoolman_tracking.pyvia_apply_spool_colors_to_archive. Tests: 12 new intest_usage_tracker.py(hex normalisation, the all-or-nothing slot-colour rule across single/multi/partial/no-colour/AMS-fallback cases, and end-to-end that a#000000spool rewrites a#161616archive) + 4 intest_spoolman_tracking.py(the Spoolman-mode rewrite, empty/partial/missing-archive no-ops). 70 usage + Spoolman tracking tests green; backend ruff clean. - Re-slicing across the single-nozzle / dual-nozzle boundary now fails fast with a clear message — Re-slicing a model that was sliced for a single-nozzle printer (X1C, P1S, A1, …) onto a dual-nozzle printer (H2D / H2D Pro), or vice versa, produced a cryptic BambuStudio rejection — "temperature difference of the filaments used is too large" or "G-code in unprintable area of multi-extruder printers" — and an attempt at automatically converting the embedded single-nozzle layout segfaults the slicer CLI. Full cross-nozzle-class re-slicing isn't supported yet (it needs a proper dual-nozzle
project_settingsreconciliation — tracked separately). Until then, both slice routes (POST /archives/{id}/slice,POST /library/files/{id}/slice) now detect a nozzle-class mismatch up front and reject it synchronously with a400and a plain explanation, instead of letting the user wait for a confusing slicer failure. The guard is fail-open — it only fires when both the source'ssliced_for_modeland the target printer resolve to known models of different nozzle class; an un-sliced source (first-time slice) or an undeterminable target is never blocked. As part of this, the dual-nozzle model classification — previously an inline("H2D", "H2D PRO", …)tuple duplicated across three call sites (start_print, the two K-profile routes) — is centralized intoDUAL_NOZZLE_MODELS+is_dual_nozzle_model()inprinter_models.py, and all three sites plus the new guard now consume that single source of truth. Tests:is_dual_nozzle_model(H2D/Pro/internal-codes dual, X1C/P1S/A1/H2S single, None safe);_canonical_printer_model(strips the"# "clone prefix and" 0.4 nozzle"suffix);guard_nozzle_class_reslice(blocks single→dual and dual→single, allows same-class, no-ops on an un-sliced source); and an end-to-end test that an X1C archive re-sliced for an H2D preset returns400before any job is enqueued. 305 slicer/MQTT/K-profile tests + 31 slice-API + the printer-model suite all green; backend ruff clean. - Sliced files no longer report "0 g" filament usage — A slice result — and the re-sliced archive's card — showed
filament_used_g: 0(and0 mm) even for a real multi-hour print, while the print time came through fine. Bambuddy reads filament totals from the slicer sidecar'sX-Filament-Used-G/X-Filament-Used-Mmresponse headers, and some sidecar builds simply don't populate them. Fix:ThreeMFParser._parse_gcode_headernow also reads the slicer's own totals —; total filament weight [g] : …and; total filament length [mm] : …— from the produced 3MF's G-code header (verified against a real sliced output: 126.26 g / 41661.4 mm extracted correctly). Both slice-persist paths (slice_and_persistfor library files,slice_and_persist_as_archive) now fall back to those parsed totals when the sidecar header is 0, applying the corrected figure to the stored metadata, the archive'sfilament_used_gramscolumn, and the slice response. The G-code-header read is a fallback only —slice_info.configstill wins when it carries per-filamentused_g. Tests: 2 new intest_archive_service.py(_parse_gcode_headerextracts weight + length; absent header lines leave the keys unset). 36 archive-service + 23 slice-API tests green; backend ruff clean. - A failed slice now opens an error modal instead of a toast that vanishes before it can be read — Slice failures surfaced through
SliceJobTrackerContextas a transient error toast, whichToastContextauto-dismisses after a flat 3 seconds. Now that a slice failure carries an actionable message — the slicer's own reason, e.g. "Some objects are located over the boundary of the heated bed." — 3 seconds is not enough to read it, let alone act on it. Fix: a newAlertModalcomponent (a small acknowledge-only modal: title, optional subtitle, message, single Close button; Escape / click-outside to dismiss — modelled onConfirmModalbut one button). On a failed slice job,SliceJobTrackerContextnow showsAlertModalwith the filename as subtitle and the slicer's reason as the body, instead of the error toast — the user dismisses it themselves. Successful slices keep the existing 3 s success toast; the persistent in-progress toast is still cleared on terminal state. Newslice.failedTitlekey translated across all 9 locales. Tests: 4 new inAlertModal.test.tsx(renders title/subtitle/message, Close button and Escape both fireonClose, subtitle line omitted when absent). Frontend build clean; i18n parity holds. - Re-slicing for a different printer no longer silently produces a file for the original printer — Re-slicing an archive or library file for another printer (e.g. an H2D model re-sliced for an X1C) could hand back a file still sliced for the original printer, with no error.
_run_slicer_with_fallbackhas an embedded-settings fallback built for one narrow case — a 3MF whose--load-settingspath crashes the slicer CLI (#1201) — but itsexcept SlicerApiServerErrorcaught every sidecar 5xx, including the slicer running fine and rejecting the job for a real reason: e.g. exit 204 "objects over the boundary of the heated bed" (the model is laid out for the source printer's larger bed and doesn't fit the target's) or exit 194 "temperature difference of the filaments is too large". On those, Bambuddy retried withslice_without_profiles, which slices using the source 3MF's embedded settings — i.e. the original printer — and presented the result as success. The cross-printer slice itself works (CLI logs confirm the target bed{256,256,250}was applied); the fallback was masking legitimate rejections. Fix: a new_slicer_rejection_messagehelper detects the sidecar marker that means the slicer ran and rejected the job ("Slicing failed with error from slicer:") and extracts the slicer's own reason. Such failures now surface as a400with that reason (e.g. "Some objects are located over the boundary of the heated bed.") instead of falling back. The embedded-settings fallback is kept only for genuine CLI crashes, which carry no slicer error string. Net effect: a cross-printer re-slice either succeeds for the chosen printer or tells the user exactly why it can't — it never silently returns a file for the original printer. Tests: 5 new intest_library_slice_api.py— 4 unit tests for_slicer_rejection_message(extracts the bed-boundary and filament-temp reasons, returnsNonefor a generic CLI crash so that still falls back, handles empty/unrelated text) and an integration test asserting a slicer rejection fails the job with status 400 and the slicer's reason, with no fallback retry. The #1201 fallback test and the STL terminal-failure test (both using a generic error message) still pass unchanged. 23 slice-API tests green; backend ruff clean. - Re-sliced archive now shows the printer it was sliced for, not the source's printer — Re-slicing an archive for a different printer (e.g. an X1C archive re-sliced for an H2D) produced a new archive still labelled "sliced for X1C".
slice_and_persist_as_archiveset the newPrintArchive.sliced_for_modeltosource_archive.sliced_for_model— blindly inherited from the source — even though the freshly-sliced 3MF embeds the actual target printer, whichThreeMFParseralready extracts intoparsed_metadata["sliced_for_model"](the new archive'sextra_dataJSON even had the correct value; only the dedicated column was wrong). Fix: readsliced_for_modelfrom the sliced output's parsed metadata, falling back to the source archive only if the output 3MF doesn't carry it — the sameparsed_metadata.get(...) or source_archive...pattern already used two lines up for filament type/color. Test: newTestSliceArchiveResliceModelintegration test re-slices an X1C-stamped archive with a mock sidecar returning an H2D-embedded 3MF and asserts the new archive is stamped H2D while the source stays X1C. 18 slice-API tests green; backend ruff clean. - Self-hosted Inter font now actually loads —
/fonts/*.woff2was not served (#1460 follow-up) — The browser console loggeddownloadable font: rejected by sanitizerforinter-latin.woff2on every page load. The #1460 PWA fix addedfont-facerules pointing at/fonts/inter-latin.woff2and bundled the woff2 files intostatic/fonts/, butmain.pyonly mounts/assets,/imgand/iconsas static directories — there was no/fontsmount. So/fonts/*.woff2fell through to the SPA catch-all and returnedindex.htmlwith200 OK; the browser's OpenType sanitizer then rejected the HTML-as-a-font. The woff2 files themselves are valid (verified — Inter variable, latin + latin-ext subsets). Fix: added a/fontsStaticFilesmount alongside the existing/imgand/iconsmounts. Additionally, the service worker had cached the bad response:sw.jslists the two font URLs inSTATIC_ASSETSandcache.addAll()treats the200 OKHTML as a successful fetch, so it storedindex.htmlunder the font URLs in the static cache and served it cache-first. The SWSTATIC_CACHEversion is bumped (v26→v27) so theactivatehandler purges the poisoned cache and re-fetches the real fonts on next load. The UI falls back to a system sans-serif until deployed, so there is no visible breakage — only the console warning. - Library files now display the filename, not the embedded 3MF Title (#1489, reported by needo37) — File Manager cards, search and sort keyed off
file_metadata.print_name, whichThreeMFParserlifts from the 3MF's<metadata name="Title">. That title is the in-app project title — generic"Exported 3D Model"for any Bambu Studio "Save As", a marketing title for a MakerWorld download — and almost never the filename the user actually saved. So a card forWhatever.3mfshowedExported 3D Model, and the only way to correct it was a rename round-trip (the Rename dialog's Save button is disabled while the name is unchanged, so the user had to rename to a different name and back). The slicer-output write path already droppedprint_namefor exactly this reason; the four other write paths that store parsed 3MF metadata onto aLibraryFiledid not — external-folder scan, managed multipart upload, the multi-file ZIP-upload branch, and MakerWorld import. Fix: a shared_without_print_name()helper stripsprint_namefrom library-file metadata, applied at all four import paths (and the slicer path switched to it, so there is one rule). ALibraryFile's display name is its filename; onlyPrintArchivecarries a realprint_name, and that is untouched. The now-redundant filename→print_namemirroring in the rename route is removed. A one-time data migration (_migrate_drop_library_print_name, idempotent, SQLitejson_remove/ PostgreSQLjsonbkey-removal branched onis_sqlite()) clearsprint_namefrom rows imported before the fix, so existing libraries correct themselves without the rename workaround. No frontend change —print_name || filenamenaturally yields the filename onceprint_nameis gone. Tests: 6 new intest_library_print_name.py—_without_print_name(strips, keeps siblings,Nonepass-through, no-op identity return, no input mutation, print-name-only →{}) and the migration (clearsprint_name, leaves siblings and metadata-free rows alone, idempotent). 109 library + dialect tests green; the migration's PostgreSQL branch additionally ran live against real Postgres during the integration-test app boot. Backend ruff clean. - Camera: ffmpeg's stderr is now captured when an RTSP stream stalls instead of only when ffmpeg crashes (#1395, reported by Tschipel) — A P2S support bundle taken on 0.2.5b1 (the per-model probesize fix already applied) showed the camera still failing: ffmpeg connects, stays alive 30+ seconds, emits zero JPEG bytes, the stream's 30 s
stdout.readtimes out, reconnect loop repeats — but with no ffmpeg stderr anywhere in the log to say why. Root cause was a diagnostic bug, not the camera path:_read_ffmpeg_stderrcalledprocess.stderr.read()(read-to-EOF). A stalled-but-still-alive ffmpeg — exactly the P2S RTSP failure mode — never closes stderr, so the read blocked until the 2 swait_fortimeout and returnedNone, discarding the banner + stream-analysis lines ffmpeg had already printed. ffmpeg's stderr was therefore captured only when it fully exited; the earlier "not enough frames to estimate rate" smoking gun was available only because ffmpeg crashed back then, and once the probesize bump turned the crash into a hang the diagnostic went dark. Fix:_read_ffmpeg_stderrnow drains stderr incrementally in bounded 8 KB chunks (64 KB cap), returning whatever ffmpeg has printed so far whether or not it has exited — so a hung stream is self-describing in the next support bundle. Additionally,generate_rtsp_mjpeg_streamnow logs the resolved per-modelprobesize/analyzedurationon the info-level "Starting RTSP camera stream" line (verifiable without debug logging), and the debug-level ffmpeg-command line logs the full argv with only the credential-bearing camera URL redacted, instead of hiding the entire command. No behaviour change to streaming itself — this makes the still-unresolved P2S RTSP stall diagnosable. Tests: 4 new intest_camera_stderr_summary.pycover_read_ffmpeg_stderrcapturing output from a running (un-exited, no-EOF) ffmpeg — the regression — as well as the exited case, the no-stderr-pipe case, and banner-only output summarizing toNone. 9 camera-stderr tests green; backend ruff clean. - Camera diagnostic (stethoscope) was missing from the pop-out camera window (#1395, reported by Tschipel) — The #1395 camera-diagnostic follow-up — stethoscope icon in the control bar, Diagnose button in the stream-error state,
CameraDiagnoseModal— shipped wired intoEmbeddedCameraViewer.tsxonly, the embedded camera mode. It was never added toCameraPage.tsx, the standalone window that opens at/camera/{id}whencamera_view_modeiswindow(the default). The reporter's support bundle had"camera_view_mode": "window", so they were onCameraPagethe whole time and genuinely could not see the stethoscope no matter how many container rebuilds or cache clears they tried — the JS bundle did contain thecamera.diagnosestrings (they come fromEmbeddedCameraViewer), but that component never renders in window mode. Switching to overlay mode made it appear instantly, exactly as the reporter found. Fix: ported the diagnostic intoCameraPage.tsx— theStethoscopecontrol-bar button (between Refresh and Fullscreen, matching the embedded viewer), a Diagnose button next to Retry in thestreamErrorblock, and theCameraDiagnoseModalrender. No new i18n keys —camera.diagnose.*already exist in all 9 locales. The backend per-model camera-profile fix from the same issue is view-mode-agnostic and already applied; this only makes the diagnostic reachable in the default window mode. Frontend build clean. - Camera: P2S RTSP stream no longer drops every frame after the first (#1395, reported by Tschipel) — With the stderr-capture diagnostic fix in place, a fresh P2S support bundle finally showed ffmpeg's reason for the stall:
frame=1 time=00:00:00.06 dup=0 drop=526 speed=0.0037x. ffmpeg connects fine and frames are arriving (thedropcounter climbs steadily, ~15/s) — but it emits exactly one output frame and the output clock freezes at 0.06 s. Root cause: the streaming ffmpeg command ends with-r 15, which puts ffmpeg in CFR (constant-frame-rate) mode — it drops/dupes input frames to hit 15 fps based on the source's timestamps. P2S firmware 01.02.00.00 sends an RTSP stream whose RTP timestamps don't advance (every frame is stamped ~0.06 s), so CFR sees every frame after the first as a same-timestamp duplicate and drops it. The browser gets one frame, then nothing → "connection lost", reconnect, repeat. This is why snapshot capture works on the same printer (that path has no-r, so no CFR conversion — timestamps are irrelevant) and why X1/H2 are unaffected (their firmware sends correct, advancing timestamps). The earlier "increase probesize" fix was real but had been masking this second bug — once ffmpeg got past the probe, the timestamp bug surfaced. Fix: the P2S camera profile gains-use_wallclock_as_timestamps 1as an ffmpeg input arg (via the existingextra_ffmpeg_input_argshook — no dataclass change, no other model touched). ffmpeg then rebuilds each packet's PTS from arrival wall-clock time, the output clock advances normally, and-r 15CFR conversion works as intended. Tests: 2 new intest_camera_profiles.py— the P2S profile splices the flag and value as an adjacent pair, and the default profile keeps an emptyextra_ffmpeg_input_argsso the override never leaks to X1/H2. 13 camera-profile tests green; backend ruff clean. - A backend restart mid-print no longer duplicates the job in the archive (#1485, reported by pwostran) — When the server running Bambuddy restarted during an active print, the running job was duplicated in the archive — and deleting the duplicate didn't help: every subsequent restart while the print was still running spawned a fresh one. Both support bundles confirmed it:
WARNING Found stale 'printing' archive 3 (age: 9:46:23), marking as cancelled and creating new archive→Created archive 4. On reconnecton_print_startfires (Bambuddy sees the printer running) and tries to re-attach to the existing archive inmain.py. The reliable match is bysubtask_id; the fallback is a name match plus — and this was the bug — a 4-hour staleness heuristic: a name-matchedprintingarchive older than 4h was assumed dead, markedcancelled, and a new archive created. Bambu prints routinely run far longer than 4h, so a genuine long print's live archive was destroyed and duplicated on every restart. Two root causes, both fixed. (1) Queue/scheduled archives never persisted a restart-stablesubtask_id. Bambuddy mints a per-job id (project_id/subtask_id/task_id) insidestart_printwhen it sends theproject_filecommand, and the printer echoes it back — but often not within the ~10s beforeon_print_startfirst fires, so the expected-print branch'sif subtask_id and not archive.subtask_idwrite got an empty value and the archive was left with no id. A later restart then had nothing to match on and fell through to the fragile name path. Fix:BambuMQTTClient.start_printnow records the minted id onlast_dispatch_subtask_id, andon_print_startfalls back to it when the printer hasn't echoedsubtask_idyet — so every dispatched archive persists a stable id and a restart resumes it by id, age-independent. (2) The 4-hour cutoff itself. Replaced with a progress-aware check: when a name-matchedprintingarchive is found on restart, the printer's current reported progress decides resume-vs-stale, not wall-clock age. Real progress (or unknown progress — printer offline) always resumes the existing archive. It is only treated as a stale leftover when the printer clearly shows a different, freshly-started print — under 1% progress on an archive more than 2h old, a state a real in-progress print is never in. The arbitrary 4h constant is gone. Net effect: a restart mid-print resumes the existing archive (started_at, energy, timelapse intact) instead of ever cancelling it and creating a duplicate. Tests: 2 new intest_bambu_mqtt.py(start_printrecordslast_dispatch_subtask_id, and updates it per submission); newTestStaleVsResumeintest_subtask_archive_resume.py— 6 cases pinning the progress-aware decision (long print mid-run resumes; barely-started long print resumes; ~0% + old archive is stale; ~0% + young archive resumes; unknown progress never cancels; the sub-1%/2h boundary). 472 print-start / MQTT / scheduler / dispatch tests green; backend ruff clean. - File Manager no longer polls the printer over FTPS every 30 seconds while open (#1480, reported by OscarsWorldTech) — The reporter's P1S churned through MQTT disconnect/reconnect cycles and timelapse downloads silently failed. The support bundle showed the real picture: during the churn windows, MQTT (
Connection stale - no message for 60.2s), FTPS (_ssl.c:1015: The handshake operation timed out) and the camera all timed out together and recovered together — the P1S's embedded controller saturating, not a network fault (wifi -44 dBm, Docker host networking). A visible contributor on Bambuddy's side:FileManagerModal.tsxran itsgetPrinterFilesquery withrefetchInterval: 30000, so every 30 s while the File Manager modal sat open it opened a fresh FTPS connection — full TLS handshake — to re-list the current directory. A printer's file list doesn't change on its own; it only changes on upload / delete (the modal's mutations alreadyinvalidateQueries) or when a print finishes. The blind 30 s poll was pure load, and on a fragile controller like the P1S it was enough to tip MQTT and FTP into the timeouts above. Fix: therefetchIntervalis removed. The listing still refreshes on modal open, on directory / tab change (the path is in the query key), after every upload / delete, and via the existing manual Refresh button — so nothing stops updating, the printer just isn't hammered. Reduces steady-state FTPS connection load while the modal is open from one handshake every 30 s to zero. 19 FileManagerModal tests green; frontend build clean. - STL thumbnail generation failures now log a full traceback — Surfaced by the #1480 support bundle: every STL in the reporter's library failed thumbnail generation with
unsupported operand type(s) for /: 'str' and 'str', butgenerate_stl_thumbnail'sexcepthandler logged only the bare exception message — no traceback, no line number. The fault could not be reproduced from a clean STL across path shapes (#and spaces in the path),strvsPatharguments, or large meshes that exercisesimplify_quadric_decimation, so it is data- or environment-specific and the message alone is not enough to locate it.stl_thumbnail.pynow passesexc_info=Trueon that warning, so the next support bundle carries the traceback and the exact failing line. No behaviour change to thumbnail generation itself. - Slicer: the Process / Filament dropdowns now filter by printer using the uploaded Slicer Bundles instead of guessing from preset names (#1325, reported by IndividualGhost1905) — After the printer-preset pre-selection landed, the reporter found the Process Profile dropdown still showed a flat mix of
BBL X1CandBBL P2Spresets with the printer set to X1C — P2S presets that should have dropped into the trailing "Other printers" group sat in the main list. Root cause:frontend/src/utils/slicerPrinterMatch.tsresolved each cloud / standard preset's printer by parsing theBBL <model>suffix of its name against a hard-codedKNOWN_MODEL_CODESallow-list. That list (X1C, X1E, X1, P1S, P1P, A1M, A1, H2D, H2S) was missingP2S(andH2C,X2D), so everyBBL P2Spreset parsed to an empty model-code set,presetCompatibilityreturnedunknown, and the dropdown keepsunknownpresets in the main list (onlymismatchmoves to "Other printers"). It was a maintenance trap by construction: every new Bambu model silently broke filtering until someone edited the list. Fix: the name-suffix heuristic and both hard-coded model tables (KNOWN_MODEL_CODES,PRINTER_NAME_PATTERNS) are removed. Compatibility is now read from ground truth — the user's uploaded Slicer Bundles (.bbscfg). Each bundle is scoped to one printer and lists the process / filament presets it ships, so "process P works with printer X" holds exactly when some uploaded bundle for printer X contains P.buildCompatibilityIndexbuilds apresetName → {printer names}index per slot fromGET /slicer/bundles(already fetched by the modal), andpresetCompatibilityconsults it — still preferring an imported preset's owncompatible_printerslist when present. A newly released Bambu model is covered the moment its bundle is uploaded, with no code change. Presets no bundle covers stay in the main list (unknownis never hidden), so a user with no bundle imported sees the un-filtered list rather than a wrong one.printerPresetCode/presetModelCodesare gone;SliceModalpasses the bundle-derived index toPresetDropdown,pickProcessDefault, andpickFilamentForSlotin place of the old model code. Tests:slicerPrinterMatch.test.tsrewritten — 12 tests coveringbuildCompatibilityIndex(per-printer mapping, multi-bundle union,#user-clone-prefix stripping, empty-printer skip) andpresetCompatibility(imported-tiercompatible_printersexact match, bundle-driven match / mismatch / unknown, the #1325 P2S-into-X1C repro, no-bundles and no-printer-selected cases). 32 SliceModal tests green; frontend build clean; backend ruff clean. - PWA: Bambuddy can now be installed as an app on Android, and the font is self-hosted (#1460, reported by Soopahfly) — Reporter could install Bambuddy as a PWA on desktop but not on an Android phone (Pixel 9 Pro XL, failing in both Chrome and Edge). A thorough remote-DevTools trace confirmed the manifest was valid, all icons/screenshots present, and the service worker activated, running, and controlling the page — DevTools reported no installability blockers — yet no install prompt ever appeared. Two root causes, both Bambuddy-side. (1) No in-app install trigger. Chrome for Android removed the automatic install mini-infobar in Chrome 108 (2022); since then a site must either listen for
beforeinstallpromptand surface its own button, or the user must dig into the browser's ⋮ menu. Desktop Chrome still auto-shows the omnibox install icon, which is exactly why desktop "worked" and Android didn't. Bambuddy had nobeforeinstallprompthandler at all, so on Android there was no discoverable install path. Newfrontend/src/components/InstallAppButton.tsxcaptures thebeforeinstallpromptevent (callingpreventDefault()so the button is the single predictable entry point), re-fires it on click, shows a success toast on accept, and clears the captured prompt afterwards (a prompt can only be used once). It renders nothing when there is no pending prompt — already installed, unsupported browser, or iOS Safari (no programmatic install) — so it never shows a dead button. Added to both the expanded and compact sidebar-footer rows inLayout.tsx, next to the GitHub link. Newnav.installApp/nav.installAppSuccessi18n keys with real translations in all 9 locales (en/de/es/fr/it/ja/pt-BR/zh-CN/zh-TW). (2) Inter font loaded from the Google Fonts CDN.frontend/src/index.csspulled Inter viaimport url('https://fonts.googleapis.com/css2?...'). For a local-first, offline-capable PWA this is wrong: it leaks a request to Google on every load, breaks the UI font when offline, and — as the reporter's trace showed — triggered CSP console errors (connect-srcdoesn't allowfonts.googleapis.com). The service worker made it worse: a request tofonts.googleapis.com/css2has path/css2(no.cssextension), so it missed the CSS branch, fell through to the catch-all HTML branch, and on failure was answered with the cachedindex.html— which is why the font request came back astext/html. Fix: the two Inter variable woff2 files (latin + latin-ext, one file covers every weight via the variable axis) are now bundled infrontend/public/fonts/and declared withfont-faceinindex.css, served same-origin. The service worker now (a) skips all cross-origin requests entirely — letting the browser handle them so a failed cross-origin fetch can never be answered withindex.htmlagain — (b) caches the font files inSTATIC_ASSETSand via a.woff2//fonts/match in the cache-first branch so the UI renders offline, and (c) bumps its cache version. With Inter self-hosted,fonts.googleapis.comandfonts.gstatic.comwere dropped from the SPA and gcode-viewer CSP directives inbackend/app/main.py(the/docsReDoc/Swagger CSP keeps them — that third-party UI genuinely loads Google Fonts). Frontend build clean, i18n parity green across 9 locales, backend ruff clean, 17 security-header tests green. - Flow Calibration now actually runs when the print option is enabled (#1478, reported by andreirusu99) — Reporter on an H2S saw poor extrusion around corners (classic too-high K factor); the printer's flow-dynamics calibration step never appeared in the pre-print checklist even with Flow Calibration toggled on in the Re-print dialog, while the same 3MF printed from Bambu Studio did calibrate. Root causes, both in the
project_filecommand built bystart_print(backend/app/services/bambu_mqtt.py): (1)extrude_cali_flagwas hardcoded to0. A BambuStudio request-topic capture from a real H2D (plus X1C and P2S captures) shows BambuStudio always sends1(run flow-dynamics calibration) or2(skip, reuse the stored PA value), paired withflow_cali, and never0— so the printer skipped calibration regardless of the toggle. (2) An earlier revision integer-encoded the calibration/leveling fields (timelapse,bed_leveling,flow_cali,vibration_cali,layer_inspect) for the H2 family (H2D/H2S/H2C/X2D) on the belief that H2 firmware required0/1; the same H2D capture disproves this — BambuStudio sends plain JSON booleans for every model. The "integer required" claim conflated these fields withuse_ams, which genuinely must stay boolean (H2D Pro reads an integeruse_amsas a nozzle index — the actual #1386 cause). Fix:extrude_cali_flagis now1 if flow_cali else 2, and the five calibration/leveling fields are sent as JSON booleans for all models, matching BambuStudio's wire format exactly. The H-family integer-conversion branch (is_h_family) is removed.use_amsis unchanged. This affects only the outbound print command; the virtual-printer inbound coercion of slicer integer0/1flags (#1403) is a separate path and untouched. Tests: intest_bambu_mqtt.py, the two tests that asserted the integer format (test_x2d_uses_integer_format_for_calibration_fields,test_h2s_keeps_integer_format_for_calibration_fields) are corrected to assert booleans and renamed; all three model tests (X2D/H2S/P2S) now also assertextrude_cali_flagis1when flow cali is on and2when off. 381 mqtt + virtual-printer tests green; backend ruff clean. - OpenSpoolman-tagged spools are now selectable in the AMS-slot assignment picker (#1122, reported by mithkr) — Reporter runs Bambuddy alongside OpenSpoolman. OpenSpoolman writes a generated NFC tag value into the Spoolman
spool.extra.tagfield; any spool it had tagged then never appeared in Bambuddy's "Select a spool" picker (LinkSpoolModal), so a spool that was physically unassigned could not be linked to an AMS tray. Root cause:GET /spoolman/spools/unlinked(backend/app/api/routes/spoolman.py) classified a spool as "linked, hide it" purely on presence of a non-emptyextra.tag. That was a stale proxy.extra.tagis only an RFID/NFC matching key — both Bambuddy and OpenSpoolman write a tag identifier there, for the same purpose — and its presence says nothing about whether the spool occupies an AMS slot. Bambuddy already has a dedicated ledger for that: thespoolman_slot_assignmentstable, whichmodels/spoolman_slot_assignment.pyitself documents as "the source of truth for Spoolman slot assignments". Fix:get_unlinked_spoolsnow decides assignability from that ledger — a spool is assignable iff its id is not inspoolman_slot_assignments— and ignoresextra.tagentirely. Verified the ledger is complete: bothlink_spool(manual link) and the AMS auto-sync (spoolman.pyslot-change persistence) upsert a row for every occupied slot, so nothing genuinely assigned can leak back into the picker.get_linked_spoolsandfind_spool_by_tagkeep usingextra.tagunchanged — those are genuine tag-match maps and are unaffected. Internal-inventory mode needs no parallel change: it stores tags in its own DB with no Spoolmanextracollision, so the OpenSpoolman conflict cannot occur there. Visible behavior change: a spool Bambuddy linked but whose slot assignment was later cleared now re-appears as assignable — correct, since it is genuinely re-linkable. Tests:test_spoolman_api.py—test_get_unlinked_spools_successnow asserts a spool with a non-empty OpenSpoolman-styleextra.tagbut no slot row still appears in the picker; newtest_get_unlinked_spools_excludes_slot_assignedseeds aSpoolmanSlotAssignmentrow and asserts that spool is excluded while a tagged-but-unassigned spool is included. 50 spoolman-API integration tests green; backend ruff clean. - Missing-spool-assignment notification no longer false-fires on every Spoolman-mode print (#1473, reported and root-caused by ojimpo) — Reporter on Spoolman mode (AMS 2 Pro, all four trays bound to Spoolman spools via the Assign-Spool UI) got a
print_missing_spool_assignmentnotification on every print start — 13 false positives in 7 days — each flagging trays that were correctly bound. He traced it precisely:backend/app/services/spool_assignment_notifications.pyqueried only the legacySpoolAssignmenttable, neverSpoolmanSlotAssignment. In Spoolman mode the legacy table is empty (bindings live inspoolman_slot_assignments, the source-of-truth since #1119), soassigned_global_trayscame back empty and every used tray was reported missing. Same class of miss as #1459 (the weight tracker also skippedSpoolmanSlotAssignment) and a [[feedback_inventory_modes_parity]] violation — a check present in both modes was wired only for legacy. Fix: the assigned-tray set is now the union of both tables —SpoolAssignmentandSpoolmanSlotAssignmentrows for the printer. Both exposeprinter_id/ams_id/tray_idin identical shape (verified againstmodels/spoolman_slot_assignment.py, whoseams_idrange 0-7 / 128-191 / 255 is fully covered by the existing_global_tray_from_assignment()), so the helper works on either unchanged. The union is strictly safe: it can only add assignments, so it never regresses legacy-mode behavior and never reports a genuinely-unassigned tray as covered. Scope note: this does not add RFID-extra.tagresolution (a tray bound purely via the loaded spool's RFID tag with no slot-assignment row) — that needs the Spoolman client and is a deeper change; the reported false positive is entirely covered by the union since the Assign-Spool UI writesSpoolmanSlotAssignment. Tests: 3 new intest_spool_assignment_notifications.py(the reporter's suggested cases) — Spoolman-only binding suppresses the notification; Spoolman partial coverage flags only the uncovered tray; mixed-mode (A1 legacy + A2 Spoolman) union covers all used trays. The test fake now routesexecute()by target table so either mode can be exercised; the existing legacy-mode test still passes unchanged. 4 notification tests green; backend ruff clean. Audit follow-up: a sweep of everySpoolAssignmentconsumer confirmed the other internal-mode-only users (usage_tracker.py,spool_tag_matcher.py,routes/inventory.py) are correct — internal and Spoolman modes have parallel implementations by design — but surfaced an asymmetry inroutes/settings.py: the Spoolman-mode toggle clearedSpoolAssignmentwhen switching on but never clearedSpoolmanSlotAssignmentwhen switching off, so stale Spoolman rows lingered. Harmless before, but now that the notification unions both tables those stale rows would wrongly count as "assigned" in internal mode and suppress a legitimate warning. Added the symmetric clear — switching back to internal mode now deletesSpoolmanSlotAssignmentrows, mirroring the existing on-switch behavior. 1 integration test intest_spoolman_slot_assignments.py::TestModeSwitchClearsAssignmentscovers it; 23 slot-assignment + 45 settings/slot tests green. - Local Profiles: the search bar no longer disappears when a query matches nothing (#1470, reported by pwostran) — Typing a query in Settings → Local Profiles that matched no preset made the search bar itself vanish, leaving the user unable to clear or edit the query without a full page refresh. Root cause in
frontend/src/components/LocalProfilesView.tsx: the search bar was gated on{totalCount > 0 && …}, andtotalCountis the sum of the post-filterfilaments/printers/processeslengths — so the moment the query filtered every column to empty,totalCounthit 0 and the search bar unmounted along with the columns. ThetotalCount === 0"No local presets yet" empty state then took over, which also misleadingly implied nothing was imported. Fix: addedhasAnyPresets, computed from the pre-filter preset counts (presets?.filament/printer/processlengths), and gated the search bar on that instead — it stays mounted as long as any preset exists, regardless of the query. The empty state is now split:!hasAnyPresetsshows the genuine "No local presets yet" + import hint, whilehasAnyPresets && totalCount === 0shows a new "No presets match your search" message (with a search icon) so the two cases are no longer conflated. NewnoSearchResultsi18n key added with real translations in all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). Tests: 1 new inLocalProfilesView.test.tsx— types a non-matching query and asserts the search bar is still in the DOM, retains the typed value, and the no-matches message renders. 10 LocalProfilesView tests green; i18n parity 4859 keys × 8 locales; frontend build clean. - Failure Detection: the Status panel's Low / High thresholds now reflect the selected sensitivity (#1469, reported by JohnMacOB) — Reporter changed the Sensitivity dropdown (Low / Medium / High) in Settings → Failure Detection and the "Low / High thresholds" readout in the Status panel never moved off
0.38 / 0.78, so the setting looked dead. Detection itself was always correct — the classifier atbackend/app/services/obico_detection.py:280usesclassify(score, settings["sensitivity"])with the real value, so warnings/failures triggered at the right confidence for the chosen level. The bug was display-only:ObicoDetectionService.get_status()computed the displayed thresholds with a hardcodedthresholds("medium")(obico_detection.py:324), ignoring the configured sensitivity.thresholds()isBASE × SENSITIVITY_MULT— low ×1.25 →0.48 / 0.98, medium ×1.0 →0.38 / 0.78, high ×0.75 →0.29 / 0.59— so the panel always showed the medium row whatever the user picked, making a working setting look broken. Fix:get_status()takes an optionalsensitivityparameter (default"medium", sothresholds()'s own unknown-value fallback still applies) and the/obico/statusroute — which already loads settings fresh and hassettings["sensitivity"]in hand — passes it through. The readout now updates the instant the dropdown change is saved (the frontend already invalidates theobico-statusquery on save), with no wait for the next poll cycle. Tests: 1 new intest_obico_detection.py::TestGetStatus—test_thresholds_reflect_configured_sensitivityasserts low > medium > high for both threshold bounds and that the default / unknown sensitivity falls back to medium. 47 obico unit tests + 5 obico API integration tests green; backend ruff clean. - Printer serial numbers are normalized on input, and a stale connection that never receives a status report now logs an actionable hint (#1465, reported by jmneely94) — Reporter's H2C connected over MQTT+TLS without error but every status field stayed
unknown(state, firmware, AMS, wifi); the P1S on the same instance worked. The report concluded the H2C firmware doesn't publish MQTT — but its own evidence disproves that: Bambu Studio LAN mode showed the H2C's live status, and Bambu Studio's device telemetry is MQTTdevice/<serial>/report(the "HTTPS API, not MQTT" claim in the report is incorrect — only file transfer/FTP and the camera stream are non-MQTT). A printer visible in Studio is publishing. Actual cause is layer-8: connect-OK + subscribe-OK + zero messages forever means Bambuddy subscribed to a topic with no traffic — the MQTT broker is the printer, it authenticates on the access code and SUBACKs a subscription to any topic string, so a wrong or mis-cased serial connects fine and silently receives nothing. The reporter's ownmosquitto_sub"verification" subscribed todevice/31b8c…/reportlowercase; MQTT topics are case-sensitive and Bambu serials are uppercase, so that test reproduced the mistake rather than validating the firmware theory. Bambuddy did nothing to guard against it:schemas/printer.pytookserial_numberas a bare string, the model stored it verbatim, andbambu_mqtt.pybuilt the topic asf"device/{self.serial_number}/report"with no.upper()/.strip(). Two hardening changes so this class of mistake self-heals or at least diagnoses itself. (1) Serial normalization: afield_validatoronPrinterBase.serial_numbernow.strip().upper()s the value (rejecting blank-after-strip), so a serial pasted in the wrong case or with stray whitespace produces the correctly-cased subscription topic.PrinterUpdatehas noserial_numberfield so the create path is the only entry point; existing DB rows are not migrated (forward fix — a mis-cased existing printer is corrected by re-adding it). (2) Zero-report diagnostic:BambuMQTTClientnow counts report-topic messages per connection (_report_messages_since_connect, reset in_on_connect, incremented in_on_messagewhenmsg.topic == self.topic_subscribe). Whencheck_staleness()fires its reconnect and that counter is still 0, it logs a one-shot WARNING (_zero_report_hint_loggedguards against spamming the 60-90s reconnect loop) telling the user the most common cause is a wrong/mis-cased serial and that the report topic is case-sensitive — turning a silent indefinite reconnect loop into something actionable in the log / support bundle. Known gap left intentionally: a printer that connects but sends literally zero messages (so_last_message_timestays 0) never tripsis_stale()at all — that grace behavior is #887-sensitive and out of scope here; the diagnostic covers the observed case where staleness does fire. Tests: 5 in newtest_printer_schema.py(uppercase, whitespace-strip, both, already-normalized no-op, blank rejected); 2 intest_bambu_mqtt.py::TestStaleReconnect(hint logs once when no reports received then stays silent on the next stale cycle; no hint when reports were received — a normal mid-session quiet gap). 659 printer/MQTT/scheduler tests green across the affected suites; backend ruff clean. - Smart-plug "Auto Off after Drying" no longer kills the printer seconds into a drying cycle (#1462, reported by Kyobinoyo) — Reporter on an X2D (firmware 01.01.00.00) set a 1-hour AMS dry and a short auto-off-after-drying delay, and the printer powered off almost immediately. The support bundle made it unambiguous: every
Sent drying command … duration=1was followed 3-9 seconds later byAMS 0 drying complete (dry_time 60 → 0)— the drying-complete callback fired seconds after drying started, not when it finished, arming smart-plug auto-off against a printer that was still drying (and potentially printing). The reporter's hypothesis was a missing print-state check; the actual cause is a false completion detection. Root cause — partial AMS-update merge dropsdry_time:backend/app/services/bambu_mqtt.pymerges partial AMS MQTT updates. The no-tray branch correctly preserved top-level fields ({**existing_unit, **ams_unit}), but the tray-bearing branch rebuilt the unit as{**ams_unit, "tray": merged_trays}— spreading only the new partial, neverexisting_unit. The printer constantly sends tray-bearing partials that carry no drying fields, so on every such updatedry_time(andinfo, which drivesdry_status/dry_sub_status) was silently dropped. The drying falling-edge detector then readint(ams_unit.get("dry_time") or 0)→ field absent →current = 0; withpreviousa real countdown value (60, 50, 52 in the reporter's log) theprevious > 0 and current == 0check fired a false "drying complete". Fix, two parts. (1) Tray-bearing merge branch now spreadsexisting_unitfirst —{**existing_unit, **ams_unit, "tray": merged_trays}— sodry_time,info, humidity, temp and any other top-level field a partial omits survive the merge, matching the no-tray branch. This also fixesdry_status/dry_sub_statusflapping in the UI on every tray update (same dropped-field bug, broader symptom). (2) Defence-in-depth in the falling-edge detector: it now only evaluates the edge whendry_timeis explicitly present (ams_unit.get("dry_time")not None) and parseable — an absent or unparseable value is skipped without touching_previous_dry_times, so a missing field can never be read as "drying finished" even if a future merge regression re-introduces a drop. Once detection is correct,on_drying_completeonly fires at real completion, so the auto-off timer arms when the user expects. Tests: 1 new intest_bambu_mqtt.py::TestDryingCompleteCallback—test_tray_only_partial_does_not_fake_completionpushesdry_time=60, then a tray-only partial with nodry_time, asserts no event fired ANDstate.raw_data["ams"][0]["dry_time"]still equals 60, then a realdry_time=0push fires the edge exactly once. 108 drying/AMS tests + 35 smart-plug-manager tests green; backend ruff clean. - 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.