Note
This is a daily beta build (2026-05-25). 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 theme detection — sidebar toggle and Settings selector follow OS dark/light preference (#1418, contributed by TempleClause via PR #1501) —
ThemeModegains a third value'system'alongside the existing'dark'/'light'. The provider listens towindow.matchMedia('(prefers-color-scheme: dark)'), tracks the OS preference in real time, and exposes a newresolvedMode: 'light' | 'dark'to consumers — the actual rendered theme after resolving system → OS preference. Layout's sidebar toggle now cyclesdark → light → system → darkwith the icon hinting at the next stop (Sun→Monitor→Moon); the existing logo selection and the dark/light "active" panel highlight in Settings switched frommodetoresolvedModeso they always reflect what's actually painted, regardless of whether the user chose explicitly or inherited from the OS. Settings → Appearance gained a 3-button Dark / Light / System selector (border-green-keys-off-modeso System actually highlights System even when it resolves to dark), with a "Settings saved" toast on click matching the adjacent Background/Accent/Style selects. Existing users' persistedtheme-modeis untouched — anyone ondarkorlightstays there and simply gains an extra stop in the cycle; new installs default todark. Review-caught fixes shipped in the same PR: (a) the project's__tests__/setup.tsmockedwindow.matchMediawithvi.fn().mockImplementation(...), whichvi.restoreAllMocks()in three test files reset to "return undefined" — pre-PR nothing calledmatchMediaat render time so the wipe went unnoticed, this PR was the first caller and broke 23 existing tests. Rewritten as a plain function (Object.defineProperty(window, 'matchMedia', { writable: true, value: (query) => ({...}) })) sorestoreAllMockscan't touch it. (b)themeToggleHinthad previously only been updated inen.ts; real translations now ship in all 8 non-English locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW) describing the 3-state cycle without referencing the old sun/moon icon pair. (c) PR description reworded to honestly call out the sidebar cycle change as a behaviour change for every user of the toggle (dark → light → systemnow intercepts where users previously gotdark → light → dark), with the persisted-preference-unchanged caveat made explicit. (d) New i18n keynav.switchToSystemwith real translations across all 9 locales ('Switch to system mode'/'Zum Systemmodus wechseln'/'システムモードに切替'etc.). Tests: 11 new inThemeContext.test.tsx(systemPreference inits frommatchMedia.matches, change event updates state, resolvedMode follows explicit mode vs systemPreference permodevalue, dark class applied based on resolved mode,toggleModecycles dark→light→system→dark); 1 new inLayout.test.tsx(toggle button title attribute walks the cycle); 4 new inSettingsPage.test.tsx(all three buttons render, active green border keys offmode, click switches mode, click fires toast). 26 previously-broken tests inAddNotificationModal.test.tsx+NotificationProviderCardStockAlerts.test.tsx+CameraTokensPage.test.tsxpass again post-setup.tsfix. Frontend build clean (2682 modules); i18n parity green at 4995 keys × 9 locales (+1 fromswitchToSystem). Contributor handled the entire round-1 review (matchMedia mock, locale parity, PR honesty, full test coverage, toast parity,.map()refactor for the button group) in a single revision push, no follow-ups deferred. - SliceModal: "Slice all plates" toggle for multi-plate sources — Re-slicing a multi-plate 3MF (e.g. a "parted statue" project where each plate carries a different body part) required opening the slice modal once per plate, picking the printer / process / filaments every time, and ending up with one archive per plate. The footer now has a "Slice all N plates" checkbox for multi-plate sources: tick it and the "Slice" button flips to "Slice all N plates", submitting
plate=0instead of the picked plate index. The backend forwards this as the BS CLI's--slice 0"all plates" sentinel, which produces a single output 3MF whoseMetadata/plate_N.gcodeentries cover every plate — one slice call, one archive, every plate inside. Filament dropdowns also adapt: with the toggle on, they show the union of every plate's slot usage (a slot a plate-2 part paints with but plate 1 doesn't was previously invisible — the user could only pick filaments for the actively-viewed plate). The union is computed client-side from the existingplatesQuery.data.plates[*].filamentspayload, so no extra round-trip. The backendSliceRequest.platefield's range relaxed fromge=1toge=0to admit the sentinel (the schema's docstring spells out the three semantics:None→ default plate 1,0→ all plates,>= 1→ that plate). The substitute-unused-filaments pass becomes a no-op forplate=0(no concept of "unused" when every plate counts), which is correct — in slice-all mode every slot the project defines IS used by something. The toggle is hidden on single-plate / STL sources where it'd be meaningless. Cross-class slice-all is handled by a per-plate loop: BS CLI's--arrangeis project-wide, so--slice 0 --arrange 1on a cross-class source consolidates every plate's objects onto a single target bed — either packing everything onto one plate or rejecting with "Some objects are located over the boundary of the heated bed" when nothing fits. When Bambuddy detectsplate=0combined with a class crossing, it falls back to slicing each plate independently (plate=N, arrange=true), then merges the N single-plate 3MF outputs into one multi-plate 3MF inmerge_plate_3mfs— overlays each plate'sMetadata/plate_N.{gcode,gcode.md5,json,png,_small.png,no_light_N.png,top_N.png,pick_N.png}onto the first plate's base 3MF and re-assemblesMetadata/slice_info.configto list every plate's slice block. The resulting archive's totals are the sum of each plate's print time + filament usage. Newcount_plates_in_3mfparsesmodel_settings.configfor<metadata key="plater_id" .../>entries to know how many plate calls to make. Cost: N × per-plate slice time; for a 5-plate Mewtwo on H2D that's ~70s wall clock vs the single-call same-class path. Progress toast shows loop position: each per-plate sub-slice forwards the originalprogress_request_id+ callback so the toast keeps showing the sidecar's stage messages, with the snapshot augmented withmulti_plate_index/multi_plate_count— the toast renders "Plate 2 of 5 • Mewtwo.gcode.3mf — Generating G-code (47%) — 23s" instead of just elapsed time. Newslice.runningWithProgressMultiPlatei18n key translated across all 9 locales. Per-plate cover images preserved: BS CLI with--arrangeregenerates plate gcodes but rarely writes a freshMetadata/plate_N.png, so the merged 3MF would have only plate 1's cover. The merger now takes the source 3MF as an optional fallback and lifts the source's per-plate render (plate_N.png/plate_N_small.png) into the merged file when the sliced output is missing it — same fallback approach as the archive-card thumbnail fix. Final test coverage: 26 unit tests intest_slicer_3mf_convert.py(extract canonical model, count plates, merge with overlay / passthrough / source-thumbnail fallback / sorted plates, substitute unused-slot filaments) + 3 intest_slicer_api.py(arrange flag wire format on preset and bundle paths) + 9 intest_library_slice_api.py(guard no-op semantics, re-sliced thumbnail / bed_type lifts, a new cross-class slice-all integration test that mocks the sidecar, asserts the backend loops per-plate witharrange=true, and verifies the merged archive containsplate_1..plate_N.gcode) + 2 intest_archive_service.py(Auxiliaries thumbnail fallback) + 4 inSliceModal.test.tsx(slice-all toggle sendsplate=0, toggle hidden for single-plate, plus 2 pre-existing tests for the picked-plate behaviour) + 2 new inSliceJobTrackerContext.test.tsx(toast prefixes "Plate X of Y" when the snapshot carries the loop fields; no prefix on plain single-plate slices). 659 backend / 42 frontend tests green; backend ruff + frontend build + i18n parity all clean. 2 new tests inSliceModal.test.tsx(toggle sendsplate=0to the backend; toggle hidden for single-plate sources) plus updates to the existing plate-picker test for the new label scheme. All 9 locales translated. Frontend build clean, i18n parity green at 4983 keys × 9 locales. - 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
- Bug-report template: tightened fields + new Area dropdown to cut invalid-issue triage load — 170 issues have been closed with the
invalidlabel (61 of them in the last 30 days alone — roughly 1 in 5 of all closed issues), nearly always because the reporter hadn't run the in-app diagnostics or checked the documented troubleshooting page. The template now forces engagement with the tools that were already shipped. Form changes (bug_report.yml): (a) the "I ran the Connection Diagnostic" checkbox flipped fromrequired: falsetorequired: true, so the form blocks submission until the reporter has actually used the diagnostic (or knowingly lied — higher friction than reading the doc); (b) the Support Package textarea is nowrequired: trueinstead of optional, with the field's prompt rewritten to "Drag the .zip here, or explain why you cannot attach one" so users without a working Bambuddy still have a path; (c) a new required "Troubleshooting steps already taken" textarea sits between Steps to Reproduce and the printer-model dropdown, asking which wiki pages were checked and which in-app diagnostics were run — empty answers can't submit, which produces either real evidence or an admission that nothing was tried (both of which are useful for triage); (d) the pre-form markdown intro now spells out the "search → wiki → diagnostic → support package" sequence with a citation of the 1-in-5 stat so reporters understand the why before they reach the fields; (e) the final-checks list grew from one to three required confirmations (searched issues + checked troubleshooting wiki + ran Connection Diagnostic for connection/printing/camera bugs), with the wiki-checked confirmation linking to the rendered troubleshooting page. Bug categorization (the gap that motivated the rewrite): the old singleComponentdropdown only carriedBambuddy / SpoolBuddy / Both— useless for area triage. Replaced with TWO required dropdowns:Product(Bambuddy / SpoolBuddy) andArea(15 options covering the actual feature surface — connection, dispatch, filament/AMS, slicer, VP, camera, archives, stats, queue, notifications, auth, updates, UI, integrations, SpoolBuddy kiosk, plus an Other escape hatch). Auto-labeling (.github/workflows/auto-label-area.yml): on every issue open/edit, anactions/github-scriptv7step parses the Area dropdown out of the rendered issue body (matching the### Area\n\nValueblock GitHub forms produce) and applies the matchingarea:*label. Tolerant of CRLF, the_No response_placeholder, and the issue-edit re-fire path (won't re-add an already-present label). Unrecognised Area values emit acore.warningso missed sync between the form and the workflow map shows up in Actions logs. Maintainer hand-off: 15area:*labels need to be created once viagh label create(see commit message for the exact commands) — labels referenced by the workflow but missing in the repo cause theaddLabelscall to throw, so this prerequisite is load-bearing. Printer Model dropdown verified againstPRINTER_MODEL_MAPinbackend/app/utils/printer_models.py— all 13 current Bambu models present (X1 Carbon / X1 / X1E / X2D / P1S / P1P / P2S / A1 / A1 Mini / H2D / H2D Pro / H2C / H2S), no update needed. YAML syntax validated via Pythonyaml.safe_loadfor both the template and the workflow. - Settings → SpoolBuddy: CPU load tile added to the device card — The SpoolBuddy daemon's heartbeat already reports
load_avg(1/5/15 min) andcpu_countviasystem_stats(seespoolbuddy/daemon/system_stats.py), but the device card on the Bambuddy SpoolBuddy settings only rendered CPU temp / memory / disk / system uptime. Adds a fifth tile next to CPU temp showing the 1-minute load average alongside core count and a percent-of-cores readout — for a 4-core Pi:1.20 / 4 (30%). Falls back to a bare load number whencpu_countisn't reported, and the tile is hidden entirely when the daemon doesn't emitload_avg(older builds). Useful for spotting the "I2C/SPI stuck after idle overnight" pattern early — sustained high load before the bus dies points at runaway daemon work rather than a kernel hang. Translated across all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW). Frontend build clean, i18n parity green. - 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. - starlette: bump floor to
>=1.0.1to clear PYSEC-2026-161 —starletteis a transitive dep pulled in by fastapi, whose range still admits the vulnerable 1.0.0 build, so a freshpip installwould silently pick it back up. Added an explicitstarlette>=1.0.1floor inrequirements.txtunder the urllib3 pin with a why-comment matching the same pattern as the idna/urllib3 entries. Release-notes reviewed for both 1.0.1 (single fix: ignore malformedHostheader when constructingrequest.url) and 1.1.0 (the resolver actually picked up 1.1.0): three behavioural changes —FileResponsefalls back toapplication/octet-streamwhenmimetypes.guess_type()can't resolve (Bambuddy has 2FileResponsecalls without explicitmedia_type, both servingindex.htmlwhere guess_type still resolves totext/html, plus custom-icon serving inexternal_links.py:261where the new fallback is a security improvement),HTTPEndpointonly dispatches standard HTTP verbs (grepfound zeroHTTPEndpointusages in Bambuddy — pure FastAPI router code),StaticFiles.lookup_pathrejects absolute paths in requests (the 4 mounts inmain.py:5503-5525pass absolute base directories to the constructor, which is unaffected — only path-traversal-style request paths get rejected). Full backend test suite green (5300/5301; the 1 failure is a pre-existing-n 30parallelism flake unrelated to starlette and passes in isolation). Verified clean viapip-auditpost-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). - Trivy DS-0026 (
Dockerfile.testmissing HEALTHCHECK): silenced viaHEALTHCHECK NONE— The test image runspytestand exits; there is no long-running service to probe, so any HEALTHCHECK we added would be cargo-cult noise.HEALTHCHECK NONEis the documented Docker directive to explicitly opt out of any inherited healthcheck and is the way Trivy expects projects to signal "this image is not a service." Closes code-scanning alert #813.
Fixed
- Paused prints no longer inflate maintenance hours (#1521, reported by TempleClause) — The
track_printer_runtimebackground task inbackend/app/main.pycounted bothRUNNINGandPAUSEstates equally towardruntime_seconds, which feeds every hours-based maintenance interval (lubricate rods, clean nozzle, check belts, etc.). Maintenance items measure mechanical wear, and pause time involves no motion — so a print paused overnight stretched the maintenance clock forward by ~8 h without any actual wear, triggering "lubricate rods" warnings earlier than warranted. Reporter found this by code review (no support bundle), flagged it cleanly with the exact line inmain.pyand three ranked solution options. Fix: option 1 (exclude PAUSE entirely) —state.state in ("RUNNING", "PAUSE")→state.state == "RUNNING". PAUSE now follows the same path as FINISH / IDLE / PREPARE: the elapsed-time accumulator skips it, andlast_runtime_updateis cleared so a later RUNNING transition starts fresh and doesn't back-bill the pause. No setting / toggle (reporter's option 3 was deliberately the throwaway — this is a wear-tracking semantic, not a user preference); no cap (option 2) — wear during pause is zero, not "reduced". Docstring and field-comment trail updated acrossmain.py,models/printer.py:23, and the twoapi/routes/maintenance.pyroute docstrings that all previously described the field as covering "RUNNING and PAUSE states". Out of scope: retroactive backfill of existingruntime_secondsvalues — already-accumulated pause time cannot be split out, only future accumulation is fixed. Users with hours-based maintenance intervals already set will see slower accumulation going forward (the correct outcome), so a previously-near-due item may take longer to ring than under the old behaviour. Tests: 3 new intest_runtime_tracking_pause.pypinning the new contract — PAUSE does NOT accumulate and clearslast_runtime_update; RUNNING still accumulates and updates the timestamp; a non-active state (FINISH) clearslast_runtime_updateto prevent back-billing the idle time when the printer next goes RUNNING. The tests drive the actualtrack_printer_runtime()coroutine through a single iteration via patchedasyncio.sleepagainst an in-memory SQLite DB, so they catch any regression in the predicate at the call site (not just an extracted helper). Backend ruff clean; targeted 24-test rod/runtime subset all green. - Quick Stats: user-cancelled prints now have their own bucket and no longer drag down the Success Rate gauge (#1390 follow-up, reported by IndividualGhost1905) — Reporter saw
Total prints: 20 / Success: 18 / Failed: 1and asked where the 20th print went; the breakdown only showed Successful + Failed, so a cancelled run silently inflated the total without appearing anywhere. The earlier #1390 round had committed a test that locked in the bug —it('uses total_prints as denominator so cancelled/stopped events count')asserted the gauge should divide bytotal_prints, which lumped user/queue-cancelled jobs in with quality outcomes and conflated user intent with printer performance. Cause:PrintLogEntry.statushas six values in production (completed,failed,aborted,stopped,cancelled,skipped) but the Quick Stats endpoint inapi/routes/archives.pyonly counted two —completed→ Successful,status == "failed"→ Failed — and used a rawcount(*)for Total Prints, so the other four statuses ended up in Total without surfacing in any breakdown row.abortedwas particularly silent: classified as a failure elsewhere in the codebase (failure_analysis.py,main.py:430,1729) but not counted towardfailed_printsin stats. Fix: three-bucket classification across the whole stats surface, matching how the rest of the codebase already groups these statuses. Quick Stats now returnssuccessful_prints(completed),failed_prints(failed + aborted — printer-detected quality failures), and a newcancelled_prints(stopped + cancelled + skipped — user/queue interruptions). The SuccessRateWidget gauge divides bysuccessful + failedonly, so cancelling a roll because you changed your mind doesn't ding the printer's success rate — a Cancelled row in the breakdown surfaces the count so it doesn't silently vanish from Total Prints. The Failure Analysis service applies the same denominator change (failure_rate = failed / (successful + failed)) to both the headline rate and the per-week trend, so a week with no failures but several cancellations no longer reads as a misleading 0/N. Schema change is additive-safe:ArchiveStats.cancelled_printsdefaults to0so any historical fixture validating against the model still parses; the frontend type also defaults the display to0when the field is missing. i18n: newstats.cancelledkey with real translations across all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW) per [[feedback_translate_dont_fallback]]; parity script clean at 4994 leaves per locale. Tests: existingit('uses total_prints as denominator …')test inverted to assert the new behaviour (40 completed / 20 failed / 35 cancelled → gauge shows 67%, Cancelled row reads 35),cancelled_prints: 0added to the shared mock so the unchanged-display assertion (140/150 → 93%) still holds since140 / (140 + 10) = 93.33%rounds identically. 33 StatsPage tests + 6 backend stats/failure tests green; frontend build + backend ruff clean. Follow-up (cosmetic): the new Cancelled row's Ban icon rendered intext-bambu-graywhile the Successful and Failed icons used semantictext-status-ok/text-status-errortokens — reporter (IndividualGhost1905) noted the asymmetry and asked for an orange to match what Archives + notification badges use for cancelled. Switched the Cancelled row totext-status-warning(amber-500, same token family as the other two rows), so all three icons are now semantic-token-driven and the new row matches the colour the user already associates with cancelled status elsewhere in the UI. - Support bundle + bug-report submission now include the live diagnostic snapshot — Three diagnostics (Connection Diagnostic per printer, Virtual Printer Setup Diagnostic per enabled VP, Log Health Scanner) have shipped on the System page and inline in the bug-report bubble since 6bc6a1d / e222a0e / ed31b8f, but the results were only ever shown to the user — never persisted into the downloadable support ZIP or the submitted GitHub issue. A report saying "looks broken in Bambuddy" arrived with no actionable signal beyond raw logs. Fix: new
services/diagnostic_snapshot.collect_diagnostic_snapshotruns all three concurrently with an outer per-probe 15 s wall-clock cap (so a hung interface adds at most ~15 s to bundle generation regardless of fleet size —asyncio.gather, total ≈ max(per-cap) not sum). Fail-soft per probe: a crash inside one printer's check emits{"printer_id": N, "error": "..."}for that entry rather than nuking the whole snapshot — partial result beats a 500. Wired into_collect_support_info()so both flows (POST /support/bundleandPOST /bug-report/submitviasupport_info=...) pick up the newdiagnosticstop-level key without their own changes. Private-data sanitization — the diagnostic schemas embed raw IPv4 in three places (PrinterDiagnosticResult.ip_address, network-mode check'sparams.{printer_ip, host_ip}, VP diagnostic'sparams.bind_ip), and the snapshot adds printer names. None of those should leak. The snapshot now runs a recursive sanitizer on the full result tree before returning: known DB-listed values (printer name, IP, serial, access code) get the same[PRINTER]/[IP]/[SERIAL]/[ACCESS_CODE]labels the log sanitizer already applies (via the sharedcollect_sensitive_strings), and an IPv4-regex fallback catches IPs the DB doesn't know about — most importantly the Bambuddy host IP returned by_get_host_ip()and any VPbind_ipthe user picked at setup. Live-DB smoke test confirms zero raw IPv4 instances in the serialized snapshot output. Progress indicators: the bubble's "submitting" view and the System page's Download button now render a static four-line checklist showing what's running (printer connectivity → VP setup → log scan → submit/build ZIP) — communicates the longer wait honestly without faking server-side phase progress we can't actually track. Tests: 6 new intest_diagnostic_snapshot.py— empty-input shape stable, per-printer / per-VP result coverage, fail-soft on a single-probe crash,timed_outmarker when a probe exceeds the per-probe cap (test patches the cap to 0.05 s), end-to-end IP sanitization across all five field shapes (top-levelip_address,printer_ip,host_ip,bind_ip, plus IPs embedded in log-health sample lines) with a final regex sweep over the JSON-serialized result asserting zero raw IPv4 escapes, concurrent execution proof (4 × 0.2 s probes complete in < 0.5 s, would be 0.8 s sequential). Existing 27 BugReportBubble + SystemInfoPage frontend tests still pass; 9-locale i18n parity check clean (4993 leaves per locale, 9 new keys added with real translations everywhere — no English fallback). Backend ruff clean. - "Prefer Lowest Remaining Filament" now uses Bambuddy's inventory weight, not just the printer's RFID counter (#1508, reported by kleinwareio) — Reporter has an inventory spool cloned to slot 1 and the original (much further used) in slot 4 of the same P1S AMS, with the preference enabled, and the dispatch consistently picked slot 1 (the fresh clone) instead of slot 4 (the original they wanted to finish first). Root cause is the
prefer_lowestsort in_match_filaments_to_slots(print_scheduler.py): the sort key readsf.get("remain", -1)straight out of_build_loaded_filaments, which sources it from MQTT AMStray.remain— the printer firmware's own RFID-decremented value. Two problems with that signal: (a) it's only populated for Bambu RFID spools, so every non-RFID / 3rd-party / user-loaded tray reports-1and gets clamped to a sentinel — multiple non-RFID spools then tie in the sort and Python's stable sort collapses to AMS-slot insertion order, so slot 1 always wins; (b) even when set, it's the printer's counter, not Bambuddy'slabel_weight - weight_used(internal mode) or Spoolman'sremaining_weight(Spoolman mode) — the two diverge any time the user re-spools, swaps cardboard, or runs a print outside Bambuddy. The reporter is on internal-inventory mode with non-RFID spools — both failure modes apply, hence slot 1 every time. Fix: when a slot is bound to a Bambuddy / Spoolman spool, that inventory record's remaining weight becomes the sort signal. New async helper_build_inventory_remain_overrides(db, printer_id, loaded)returns{global_tray_id: remaining_grams}for slots with an assignment — internal mode joinsSpoolAssignment→Spoolonce per dispatch, Spoolman mode joinsSpoolmanSlotAssignmentthen fetches each spool through the existing_spoolman_remaining_grams(shared withfilament_deficit.py, parity rule per [[feedback_inventory_modes_parity]]). The new_prefer_lowest_sort_keyconsumes that map alongside the legacy MQTT field with a two-tier comparison: inventory-tracked spools always sort BEFORE MQTT-only spools, then ascending by remaining within each tier, then ascending byams_id * 4 + tray_idas the deterministic slot tie-breaker. The tier flag dominates so we never compare grams (inventory) against percent (MQTT) — no unit-conversion contortions. MQTT-only behaviour is preserved exactly:remain = -1still maps to the 101 sentinel and slot order still decides on ties, so users who haven't bound any spools see no change. External / VT tray slots are skipped (tracked separately from AMS bindings). Lookup runs only whenprefer_lowest_filamentis enabled — no extra DB hit for users who don't use the preference. Tests: 6 new inTestPreferLowestInventoryOverrideintest_scheduler_ams_mapping.py(inventory override beats MQTT remain — the literal reporter scenario with 950 g clone vs 50 g original; zero-grams still sorts first within its tier; inventory tier beats MQTT tier regardless of value; tied inventory grams break to lower slot; no-override falls through to MQTT — regression guard for un-tracked spools; legacyremain = -1still sentinel-sorts last when override map is None) + 7 new intest_scheduler_inventory_remain.pycovering_build_inventory_remain_overridesdirectly (internal mode returns label_weight − weight_used per bound slot; external slots skipped; empty loaded short-circuits; over-consumed spool clamps to 0 g; unbound slots absent from map; Spoolman mode uses_spoolman_remaining_gramsfor parity; Spoolman unreachability silently omits that slot). 102 scheduler + inventory tests green; backend ruff clean. - X1/H2/P2 live camera no longer fails with
Address already in useon transitional ffmpeg builds (#1504, reported by rage03usa, confirmed by PawseHaxor) — On a native Ubuntu install with the Jammy-era system ffmpeg, the RTSP live-view path retried indefinitely withUnable to open RTSP for listening … Address already in use. Snapshots, the camera diagnostic, and OrcaSlicer all kept working — only live view was broken. Cause: the ffmpeg argv built inbackend/app/api/routes/camera.py(added in 530a7a4 as part of an RTSP-stability bundle) passed-timeout 30000000. That ffmpeg version deprecated the original-timeout(socket I/O microseconds) and repurposed the name to mean the RTSP listen-mode incoming-connection timeout — any non-zero value implies-listen. ffmpeg then flipped into RTSP server mode and tried to bind the same localhost port Bambuddy's TLS proxy was already listening on, hence EADDRINUSE on every retry (the odd-port pattern PawseHaxor noticed is coincidence — the ephemeral allocator just picked odd values that run). The reporter's own workaround (drop the option) works but silently loses the socket-level read timeout, so a hung TLS handshake would block past the OS TCP timeout instead of failing fast into the existing reconnect loop. Why this can't be a one-line literal swap: ffmpeg has shipped three arrangements of this option over time and Bambuddy supports the full range. Pre-deprecation builds:-timeoutis the socket I/O timeout. Transitional builds (~late-4.x, what the reporter is on):-timeoutis the broken listen-mode option,-stimeoutis the replacement. Modern ffmpeg (5.x / 6.x / 7.x — current Debian 13, Ubuntu 24.04, current Homebrew):-stimeoutwas removed entirely and-timeoutis back to socket I/O. So both literals regress one half of the install base. Fix: a newrtsp_socket_timeout_flag()helper inbackend/app/services/camera.pyprobesffmpeg -h demuxer=rtsponce at first use and picks-stimeoutwhen ffmpeg advertises it (transitional window) or-timeoutotherwise (modern + very old). The result is cached for the process lifetime — ffmpeg won't swap mid-run. The function returns the option name without a leading dash so callers prepend it themselves (no empty-flag formatting bug). Wired into both RTSP ffmpeg call sites —routes/camera.py(printer camera) andservices/external_camera.py(external RTSP) — in lockstep, same TLS-proxy + ffmpeg pattern, same regression. The reporter had tried-listen_timeout(doesn't help — we don't want listen mode) and-rw_timeout(AVIO-level, RTSP demuxer doesn't honour it on its control socket), but no manual swap could be correct for both transitional and modern installs simultaneously. Tests: 8 intest_ffmpeg_rtsp_timeout_flag.py— 6 unit tests for the probe (picks-stimeoutwhen advertised, falls back to-timeouton modern, defaults to-timeoutwhen ffmpeg missing or probe raises, caches across calls, substring-match guard against false-positives on-listen_timeout), 2 parametrised regression guards against either RTSP ffmpeg argv re-hard-coding a literal flag instead of consuming the probe. 37 (probe + existing external-camera) tests green; backend ruff clean. - SliceModal: process / filament dropdowns now filter by nozzle diameter too, not just printer model (#1325 follow-up #2, reported by IndividualGhost1905) — With the BBL name fallback in place, the reporter saw that an X2D 0.4 selection still mixed 0.2 / 0.6 / 0.8 nozzle process variants into the main list. The fallback's regex stripped any trailing
<size> nozzlesuffix from both sides before comparing, so"Bambu Lab X2D 0.4 nozzle"and"0.40mm Strength BBL X2D 0.8 nozzle"both reduced to"X2D"and matched. The bundle path was already nozzle-correct (a.bbscfgis scoped to one printer-preset-name including its nozzle, so the bundle-side exact-match was nozzle-aware); only the name fallback needed fixing. Fix:extractPrinterPresetModelandextractBblTokennow each return{ model, nozzle }. The nozzle is the parsed string ("0.4" / "0.6" / etc.) ornullwhen the name has no suffix.classifyByBambuNametreats anullprocess nozzle as"0.4"— Bambu's convention is to omit the suffix on 0.4 (the default) and include it for 0.2 / 0.6 / 0.8, exactly as the reporter described. Bothmodelandnozzlemust compare equal for a'match'; differing nozzles fall into the existing "Other printers" group, no new group label needed. If the selected printer preset name has no parseable nozzle (non-Bambu / hand-typed), the matcher degrades to model-only — Bambu printer presets always include nozzle in practice, so this is defensive. Tests: 9 new inslicerPrinterMatch.test.tscovering the matrix (0.4 printer vs no-suffix / 0.6 / 0.8 process; 0.6 printer vs 0.6 / no-suffix-=-0.4; explicit 0.4-suffix-on-process still matches 0.4 printer; same rule on filament presets; wrong-model dominates over matching-nozzle; no-nozzle printer name degrades to model-only); one existing test reframed (the case that previously asserted a 0.6-nozzle process matched a 0.4 printer — the exact bug — now asserts mismatch). 46 slicerPrinterMatch + 34 SliceModal tests green; frontend build clean. - Timelapse now attaches to the archive after a backend restart mid-print (#1485 follow-up, reported by pwostran) — With the duplicate-archive fix from #1485 in place, a restart mid-print stopped creating ghosts — but the resulting archive came back without its timelapse video (only the finish snapshot was attached). Cause is a side-effect of the #1304 first-push guard: on the first MQTT push after Bambuddy starts (
_previous_gcode_state = None),is_new_printis deliberately False soon_print_startdoesn't fire — which prevents duplicate archive creation but also prevents the timelapse-baseline capture, since both live behind the same callback. At PRINT COMPLETE,_scan_for_timelapse_with_retriesfinds an empty_timelapse_baselinesfor the printer and falls into the "take baseline now" fallback inmain.py. By that point the printer has already uploaded the in-flight MP4, so the snapshot includes it. Every retry then reports "N files found / no new files since baseline" and the scan gives up. The reporter's support bundle is the smoking gun — pre-reboot baseline of 7 files, post-reboot fallback baseline of 8 files (including the just-uploaded one), 4 retries all unable to see the diff. Fix:bambu_mqtt.pynow fires a siblingon_print_running_observedcallback inside the "Now tracking RUNNING state" branch when the first-push guard suppresseson_print_start.main.pywires it to a thin handler that fetches the printer row from DB and calls the existing_capture_timelapse_baseline_at_start. The callback only fires the first time we observe RUNNING per session (gated on the samenot self._was_runningbranch the timelapse-flag restore already lives in), so a normal print start path is unaffected. The handler is also idempotent: if a baseline already exists for that printer, it returns without touching it. Safe because the printer doesn't upload the timelapse until after PRINT COMPLETE, so a baseline captured any time during the in-flight print is still pre-upload — no narrow window. The plumbing (set_print_running_observed_callbacksetter, in-connect_printerwrapper, constructor pass-through) mirrors the existingon_print_start/on_print_completecallback chain inprinter_manager.py. Tests: 7 new inTestPrintRunningObservedCallbackintest_bambu_mqtt.py(fires on first RUNNING after startup, doesn't double up withon_print_start, fires only once per session, skips on non-RUNNING / missing file / no-callback-set, payload shape mirrorson_print_start); 3 new in a dedicatedtest_timelapse_baseline_restart_recovery.py(handler captures the printer's existing-videos snapshot into_timelapse_baselines, skips when a baseline already exists, skips when the printer row was deleted between push and handler). 336 MQTT + print-start + timelapse tests green; backend ruff clean. - SliceModal: process / filament dropdowns now filter for users who haven't uploaded slicer bundles (#1325 follow-up, reported by IndividualGhost1905) — The original #1325 fix replaced a stale hardcoded
BBL <model>allow-list with bundle-based compatibility: a process / filament preset was classified against the selected printer by consulting the user's uploaded Slicer Bundles (.bbscfg). That works perfectly for users who have uploaded bundles for every printer their cloud catalogue covers — and silently no-ops for everyone else: every cloud preset resolves to'unknown', nothing moves into "Other printers", and the dropdown looks identical to the pre-fix state. Fix: restored theBBL <token>name fallback as a third tier below the bundle path, but with the token-to-printer mapping driven by the backend's canonicalPRINTER_MODEL_MAP(backend/app/utils/printer_models.py) instead of a duplicated frontend table. A newGET /api/v1/slicer/printer-modelsroute ships the mapping unmodified;slicerPrinterMatch.buildCompatibilityIndexaccepts it as a second arg, inverts it into a short-code → display-fragment table (X1C→X1 Carbon,P2S→P2S,A1 Mini→A1 mini, …), andpresetCompatibilityuses it only aftercompatible_printersand the bundle index have already returned'unknown'. The match is case- and whitespace-insensitive ("A1 mini","A1 Mini"and"a1mini"all compare equal). When the registry doesn't list a token, the matcher falls back to comparing the raw token against the printer-preset model fragment — so a brand-new "Q1" printer withBBL Q1-tagged presets matches without any code change. Adding a new model only requires updating the existing backendPRINTER_MODEL_MAP(already the single source of truth foris_dual_nozzle_model, the rod-type/ethernet registries, and 3MF metadata normalisation) — no frontend table to keep in sync. Tests: 2 new intest_slicer_presets.py(/printer-modelsreturns the fullPRINTER_MODEL_MAP; the route hands back a copy, not the live module dict); the existing 25slicerPrinterMatch.test.tscases were extended to 36 covering: registry-driven X1C vs X1 Carbon match, A1 vs A1 mini disambiguation, H2D vs H2D Pro disambiguation, the previously-missing P2S / H2C / H2S / X2D, raw-token fallback for unregistered models, graceful degradation when the registry fetch hasn't resolved yet, thecompatible_printers-wins-over-name rule, and the bundle-wins-over-name rule. 38 slicer-presets + 36 slicerPrinterMatch tests green; backend ruff clean; frontend build clean. - Cloudflare-fronted Bambuddy no longer needs an
unsafe-inlineoverride to load (#1460 follow-up, reported by Soopahfly) — A Bambuddy instance behind Cloudflare logged an inline-script CSP violation on every page load: Cloudflare's bot-detection script (/cdn-cgi/challenge-platform/scripts/jsd/main.js) is injected into the HTML on the edge with a hash that changes per request, so it can never be allowlisted byscript-srchash. The contributor's workaround was to relaxscript-srcto'unsafe-inline'in their Nginx Proxy Manager — which works but defeats most of the CSP. Fix: the SPA CSP now stamps a fresh per-request nonce intoscript-src('self' 'nonce-<base64>'). Per Cloudflare's documented behaviour, when a nonce is present in the CSP header Cloudflare clones the same nonce onto its injected<script>and the inline script passes without'unsafe-inline'. Bambuddy's ownindex.htmlhas had no inline scripts since the SW registration moved to/sw-register.js(#1460 first PR), so no HTML body rewriting is needed —'self'continues to cover every script the app ships. Implemented via a 16-bytesecrets.token_urlsafe()nonce computed per request insecurity_headers_middleware. Separately,/manifest.json,/sw.jsand/sw-register.jsare now registered withapp.api_route(methods=["GET", "HEAD"])instead ofapp.get— a plaincurl -I https://host/manifest.json(and several uptime scanners) HEAD-probe these routes and were getting405 Method Not Allowed, which surfaced in the issue as an apparent manifest-server bug. Tests: 3 new intest_security_headers.py—'nonce-…'is stamped into the SPAscript-srcdirective while'self'remains and'unsafe-inline'does not; the nonce is fresh per request across 5 sequential calls (collision probability ~0); HEAD on/manifest.json,/sw.js,/sw-register.jsnever returns 405. 22 security-header tests green; backend ruff clean. - Insufficient-filament pre-print warning now fires on every dispatch path (#1496, reported by needo37) — The "Pre-print checks now also warn when the spool has insufficient material" guard from #720 only fired on the
PrintModalsubmit path. Two other queue-dispatch paths bypassed it entirely: the green ▶ Play button on a staged (manual_start) queue row calledPOST /queue/{id}/start, which only flipped the manual_start flag with no filament check; and the Virtual Printer queue-mode intake (virtual_printer/manager._add_to_print_queue) parsed per-slot requirements for type matching only — never weight. Withauto_dispatch=Truethe scheduler would then dispatch unsupervised onto a doomed-to-fail spool. Fix: extracted the per-slot deficit calculation into a single backend helper (backend/app/services/filament_deficit.py) that both the route and the dispatch scheduler call against live spool state. Works for internal-inventory mode (SpoolAssignment→Spool.label_weight - weight_used) and Spoolman mode (SpoolmanSlotAssignment→SpoolmanClient.get_spool); Spoolman unreachability returns no deficit rather than wedging the queue. Thedisable_filament_warningssetting is honoured at the service boundary.POST /queue/{id}/startnow returns409 {detail: {code: 'insufficient_filament', deficit: [...]}}when short; the?skip_filament_check=truequery param is the "Print Anyway" bypass. The dispatch scheduler runs the same check just before each_start_printcall: a deficit promotes the item tomanual_start=True+filament_short=True(so the user must consciously click ▶) and a previously-flagged item whose spool was swapped to one with enough material clears the flag automatically on the next tick. A newfilament_shortboolean column onprint_queuecarries the flag; the queue row now renders a yellow "Insufficient filament for the assigned spool" badge when set, and the ▶ button catches the 409, opens anInsufficient Filament / Print Anywayconfirm modal showing each shorted slot's required-vs-remaining grams, and on confirm re-issues the start with the skip flag. Migration is idempotent and branches onis_sqlite()for theBOOLEAN DEFAULTsyntax. Tests: 8 intest_filament_deficit.py(deficit + sufficient + missing mapping + no printer + disabled-warnings + no-assignment + missing 3MF + multi-slot only-shorted-returned), 4 intest_scheduler_filament_deficit.py(block-on-deficit, clear-stale-flag, no-deficit no-op, helper-exception doesn't wedge), 2 new intest_print_queue_api.py(/startreturns 409 + structured payload,?skip_filament_check=truebypasses), and 2 frontend tests inQueuePage.test.tsx(badge renders on flagged row, ▶ click → 409 → modal → retry withskip_filament_check=true). 3392 unit + 63 print-queue integration green; backend ruff clean; frontend build + i18n parity (9 locales × 4979 keys) clean. - File Manager "All Files" view showed nothing when every file lived in a subfolder (#1499) — The sidebar entry was meant to list every file across the library but instead returned only files at the library root, so a library with two folders and three files (all nested) appeared empty. Cause was an inverted boolean on the React Query call:
getLibraryFiles(selectedFolderId, selectedFolderId === null)passedinclude_root=truefor the "All Files" selection, which on the backend (library.pylist_files) means root files only — the opposite of what the UI wanted. Fix: passinclude_root=falsefor "All Files" so the backend returns every active file across folders (it remains a no-op when a specific folder is selected —folder_idtakes precedence). A new vitest regression case renders the page with one root file and one nested file and asserts both appear, and that the request goes out withinclude_root=false. 48/48 FileManagerPage tests green; frontend build clean. - 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 actually works (#1493) — Re-slicing a model sliced for a single-nozzle printer (X1C, P1S, A1, P2S, …) onto a dual-nozzle printer (H2D / H2D Pro) — or vice versa — produced one of two BambuStudio failures: "Found G-code in unprintable area of multi-extruder printers" (the source's X1C-coordinate layout drops into the H2D's per-nozzle dead zone) or, on multi-color projects, a hard SIGSEGV inside the slicer's
ZFillerpolygon-clipping pipeline. An earlier release shipped a fail-fast400guard so the user got a clear message instead; this release lifts the guard and actually does the conversion — by forwarding the sidecar's existing--arrangeflag (it was already plumbed all the way to the CLI inorca-slicer-api/src/routes/slicing/slicing.service.ts:152; Bambuddy just wasn't surfacing it). Witharrange=trueBambuStudio repositions objects for the target bed and reconciles the embeddedproject_settings.configagainst the new printer, the same way the GUI's "Switch Printer" operation does. Empirically: a Mecha Mewtwo X1C archive that previously SIGSEGV'd on H2D now slices in 14.5s producing a 28 MB 3MF with valid H2D G-code, and the simple-test pair (#141 → H2D) which previously hit "G-code in unprintable area" also slices clean. Wired into_run_slicer_with_fallbackon a true class-crossing only (is_dual_nozzle_model(source) != is_dual_nozzle_model(target)) so single-printer slices preserve the user's deliberate layout. Threads through bothslice_with_profilesandslice_with_bundle(preset and bundle dispatch).guard_nozzle_class_reslicebecomes a kept-for-compat no-op; call sites inarchives.pyand the library re-slice route remain so external forks don't break their links. The earlieris_dual_nozzle_model()/DUAL_NOZZLE_MODELScentralisation stays put — the new cross-class detector reuses it. A separate related bug surfaced during testing: the SliceModal lets the user pick a filament profile per slot, but each plate only uses a subset of those slots. The unused-slot dropdowns get whatever default the modal serves up — and a heterogeneous default (e.g. ABS in slot 2 next to a PLA in the used slot 1) makes BambuStudio reject the slice with "the temperature difference of the filaments used is too large" (exit 194), even though the plate's G-code never touches the unused slot. Bambu validates every loaded filament for material compatibility regardless of which slots are actually used. Fix: a genericsubstitute_unused_plate_filamentshelper runs for both preset and bundle dispatch when a 3MF + plate are involved. It reads the source 3MF's per-plate extruder set via the existingextract_plate_extruder_set_from_3mf(the same logic that drives the modal's "not used by this plate" label) and overwrites any unused-slot entry with slot 1's selection before the slice. The per-slot array length stays intact (so source-3MF references still resolve), the loaded-filament set becomes materially homogeneous (so the validator passes), and the plate's G-code is unaffected because it never touched the unused slots in the first place. Fail-open everywhere — no plate, unparsable 3MF, single-filament list, or empty extruder-set parse all return the input unchanged. Applies to same-class slices too, which is why this lives outside the cross-class branch. Tests: 17 intest_slicer_3mf_convert.pycoveringextract_source_printer_model(returns canonical short codes, an integration check that the result feeds straight intois_dual_nozzle_modelend-to-end since the raw field is"Bambu Lab H2D"not"H2D", handles malformed/non-zip/empty inputs) andsubstitute_unused_plate_filaments(substitutes unused slots, no-op when all used / no plate / single filament / unparsable source); 3 new intest_slicer_api.py(preset and bundle paths both emit a multipartarrange=truefield when set, omit it entirely when default-false so the pre-#1493 wire shape is preserved); guard suite intest_library_slice_api.pyrewritten to assert the no-op semantics. Empirically validated end-to-end against the live sidecar: X1C source 3MF + H2D triplet +--arrangeslices clean for both the simple test pair and the multi-color Mecha Mewtwo statue. Card display fixes for re-sliced archives: BambuStudio CLI rarely emits a freshMetadata/plate_N.pngfor the sliced plate (slice writes the new gcode but leaves the preview slot empty — and--arrangedoesn't change that), so the previous per-plate preview the archive card relied on was simply missing on every re-sliced output. The slice route now picks the cover image in this order: (1) the source archive'sMetadata/plate_{N}.png— the GUI-rendered preview of the same plate, which is what the user expects to recognise on the card; (2) the sliced output's own per-plate render if BS did happen to write one; (3) the project-wide thumbnail underAuxiliaries/.thumbnails/(_middle.png/_small.png/_3mf.png) — the MakerWorld-style cover art that gets embedded at project import time. Without (1), the card always fell all the way through to (3) and ended up showing marketing art rather than a render of the actual print. A new_read_3mf_entryhelper extracts a single zip entry safely (no parser overhead, fails open on bad zips). TheThreeMFParserfallback chain was extended to include the Auxiliaries paths so the non-archive callers (library files, etc.) also benefit when neither per-plate variant is present. Second card gap: the re-sliced archive'sbed_typelived inextra_databut not on the top-levelPrintArchive.bed_typecolumn theArchiveCardactually reads — lifted it through, with a fallback to the source archive's value when the sliced output is sparse. 2 new tests intest_archive_service.py(Auxiliaries thumbnail fallback works, per-plate preview wins when both are present) and 4 intest_library_slice_api.py(re-slicedbed_typereflects the slicer's curr_bed_type and falls back to source on missing; re-sliced thumbnail prefers source's per-plate render over the Auxiliaries cover, and falls back to Auxiliaries when the source has no per-plate render either). 647 archive/library/slice tests 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
Changelog truncated — see the full CHANGELOG.md for the complete list.