Note
This is a daily beta build (2026-06-21). 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.
Security
- Vite 7 → 8 major bump — Bambuddy's frontend now builds with Vite 8 (
^7.3.2→^8.0.16) and the matching plugin-react release (vitejs/plugin-react^5.1.1→^5.2.0). Headline architectural change: Vite 8 swaps Rollup for Rolldown as the default bundler — same plugin contract, Rust-backed core, slightly different chunk layout / output bytes (no functional regression). The bump also lifts the transitiveesbuildfloor to 0.28.1, which closes the last open advisory in the audit chain. Bambuddy-side surface audited:vite.config.tsuses only stable contracts that survived the v8 cut —defineConfig, theConnecttype, the customserveGcodeViewerconfigureServermiddleware plugin (proxies/gcode-viewer/*to the repo's siblinggcode_viewer/directory in dev), theserver.proxywith WebSocket upgrade for/api/v1/ws,build.outDir/emptyOutDir/chunkSizeWarningLimit, andresolve.aliasfor ``.base: '/'regression guard from #1221 is unaffected. No SSR, no library mode, no CSS preprocessors, no exotic plugins. `vitest4.1.8` already accepts vite 8 in its peer range (`^6 || ^7 || ^8`); no test-runner bump required. Node: vite 8 requires `^20.19.0 || >=22.12.0`; CI Node 20.x line satisfies this. What this is NOT: plugin-react v6 — that line requires `babel-plugin-react-compiler` + `rolldown/plugin-babel` as peers and is a separate scope. `npm run build`, `npm run lint`, `npx vitest run` all clean; `npm audit` clean. - Frontend dependency bumps — Routine version updates across the runtime, build, and test dependency surface. Runtime:
dompurify3.4.0 → 3.4.10.package.jsonfloor raised from^3.4.0to^3.4.10so fresh installs cannot land on the deprecated 3.4.4 release. Three call sites use string-output sanitisation (frontend/src/pages/MakerworldPage.tsx,frontend/src/pages/ProjectDetailPage.tsx,frontend/src/components/ProjectPageModal.tsx); release notes 3.4.1 → 3.4.10 reviewed for behavioural changes — 3.4.4 widened the default allow-list withselectedcontent+command+commandfor(all valid modern HTML, harmless for our two default-allow-list call sites), andProjectPageModalis unaffected anyway because it sets an explicitALLOWED_TAGS/ALLOWED_ATTRwhitelist. Build / lint / test tooling (transitive, dev-only):babel/core7.29.0 → 7.29.7 (pulled byvitejs/plugin-reactandeslint-plugin-react-hooks),vite7.3.2 → 7.3.5,markdown-it14.1.1 → 14.2.0 (pulled bytiptap/extension-link→tiptap/pm→prosemirror-markdown; Bambuddy never callsmarkdown-it.renderdirectly so the change is transparent),js-yaml4.1.1 → 4.2.0 (pulled byeslint),form-data4.0.5 → 4.0.6 +ws8.20.1 → 8.21.0 (both pulled byjsdomin the test runtime). All bumps inside existing semver ranges exceptdompurify. No source changes required. dompurify3.4.10 → 3.4.11 — Follow-up patch closes a moderate-severity advisory affectingsetConfig()callers: the previous hook clone-guard added in 3.4.7 could be bypassed viasetConfig(), leaving a permanentALLOWED_ATTRpollution that the nextsanitize()call inherited. Bambuddy's exposure is nil —git grep DOMPurify.setConfigreturns zero hits across the entire codebase; all three sanitisation sites (frontend/src/pages/MakerworldPage.tsx,frontend/src/pages/ProjectDetailPage.tsx,frontend/src/components/ProjectPageModal.tsx) callDOMPurify.sanitize(html)orDOMPurify.sanitize(html, {ALLOWED_TAGS, ALLOWED_ATTR})directly, never throughsetConfig(). The bump is taken as defence-in-depth to keep XSS-sensitive surface area current and to silencenpm auditso future audit-fix runs don't auto-bundle unintended changes. Mechanical lockfile bump only: the existing^3.4.10range already permitted 3.4.11, sopackage.jsonis unchanged;package-lock.jsonupdates the resolved URL + integrity hash for the one entry. Verification:npm auditreports 0 vulnerabilities,MakerworldPage.test.tsx's 12 DOMPurify sanitisation cases pass,npm run buildclean.- Backend dependency security floor raises (cryptography / python-multipart / starlette) — pip-audit December 2026 cycle surfaced six advisories across three direct deps; floors in
requirements.txtlifted to the documented fix releases, plus one transitive co-bump for resolver compatibility.cryptography46.0.7 → 48.0.1 floor (resolver picks 49.0.0 within the new floor) — clears GHSA-537c-gmf6-5ccf (non-contiguous Python buffer handling that could overflow on APIs accepting buffer protocol input). Release-notes audit (done before bump): v47.0.0 dropped Python 3.8 + OpenSSL 1.1.x + binary elliptic curves (SECT*) + Camellia + CFB/OFB/CFB8 modes (moved tocryptography_decrepit); v48.0.0 droppedPUBLIC_KEY_TYPES/PRIVATE_KEY_TYPEStype aliases. Bambuddy's grep is clean across every one of those:core/encryption.pyuses Fernet (AES-128-CBC + HMAC),services/spoolbuddy_ssh.pyuses ed25519,services/virtual_printer/certificate.pyuses RSA + x509 + ExtendedKeyUsageOID. Python 3.13 + OpenSSL 3.x on container, so the version-floor bumps are no-ops for us.python-multipart0.0.27 → 0.0.31 floor (resolver picks 0.0.32) — clears CVE-2026-53538/53539/53540 in the multipart parser surface (boundary length capped at 256 bytes, RFC 2231 continuation handling, Content-Length non-negative validation, bounded header field name size before validation). Behavioural changes audited: 0.0.30 stopped recognising RFC 2231/5987 extendedfilename*/name*parameters in incoming bodies — Bambuddy emits these on outgoing Content-Disposition response headers (utils/http.py:17) but doesn't parse them on the request side, and clients that include bothfilename=andfilename*=keep working via the plainfilename=fallback (slight cosmetic difference for non-ASCII filenames in uploads). 0.0.30 also tightened form-urlencoded parsing to treat only&as field separator — every Bambuddy client (browser, BambuStudio, OrcaSlicer) already uses&.starlette1.1.0 → 1.3.1 floor — clears CVE-2026-54282/54283 (FormParsermax_part_size/max_fieldslimits now actually enforced after being declared-but-ignored in earlier releases;StaticFiles.lookup_pathrejects absolute paths;FileResponseclamps oversized suffix range requests;URL.replace()IndexError fix). Critical pre-bump check: the newly-enforcedmax_part_size=1MBdefault would have broken every file upload (UploadFile = File(...)ininventory.py:1127,projects.py:886/1053/1780,library.py:1787,local_presets.py:82,external_links.py:166,local_backup.py) if it applied to file streams. Inspected theMultiPartParser.on_part_datasource: the size check atif self._current_part.file is None:only fires for text form fields, not file streams — so file uploads of arbitrary size still pass through unaffected. Text form bodies in Bambuddy are login credentials and similar small values, well under the 1MB ceiling. Side rename:backend/app/api/routes/mfa.py:470/1364/1428replaces 3 references ofstatus.HTTP_422_UNPROCESSABLE_ENTITY(deprecated in starlette 1.3.x) withHTTP_422_UNPROCESSABLE_CONTENT. Same 422 wire status; silences the 3 deprecation warnings under our own ownership (the two remaining warnings come from FastAPI internals — upstream's to fix).pyopenssl26.0.0 → 26.3.0 floor — NOT a security fix; required because pyOpenSSL<26.3.0capscryptography<47in its install_requires, so without an explicit floor the resolver either downgrades cryptography below the GHSA-537c-gmf6-5ccf fix line or installs an inconsistent pair (pip's resolver warns but proceeds). Bambuddy has no directfrom OpenSSL ...imports — pyOpenSSL is pulled transitively byasyncssh+pywebpush. Verification:pip-auditclean,pip checkclean,ruff check backend/clean, backendpytest -n 306167/6167 in 86.55s. No DB migration, no API surface change, no permission change, no frontend change.
Added
- Page-wide drag-and-drop upload on the File Manager (#1510, requested by maikolscripts) — File Manager gains the same drag-and-drop upload surface that the Archives page has had: drop any file anywhere on the page and the upload modal opens pre-populated with the dropped files, no need to click the Upload Files button first. The hardcoded
"Upload 3MF"flow was the only path before this change. Unlike the Archives variant — which filters dropped files to.3mfonly — the File Manager drop zone accepts whatever the upload modal itself accepts (3MF, STL, ZIP, images), so the page-wide surface is never more restrictive than the button it shortcuts. Permission-gated onlibrary:uploadso a viewer-tier user can't accidentally trigger the overlay. Shared hook.frontend/src/hooks/usePageFileDrop.tsis the new home for the drag-handler set —isDraggingOverstate,dragHandlersto spread on the wrapper, optionalextensionsfilter, optionalonRejectedcallback for "you dropped something we won't accept" toasts,disabledflag for permission gating. Archives and File Manager both consume it; future drop-zones can opt in without re-implementing the cancel-safe logic.FileUploadModal.initialFilesprop. Modal accepts aFile[]to pre-seed itself on first mount via aseededInitialRefguard so the same files don't re-add on subsequent renders. Existing manual-open paths (Upload Files button) pass nothing and behave unchanged. i18n. New keyfileManager.releaseToUploadtranslated in all 11 locales (en: Release to upload, de: Loslassen zum Hochladen, es: Suelte para subir, fr: Relâcher pour téléverser, it: Rilascia per caricare, ja: 離してアップロード, ko: 놓아서 업로드, pt-BR: Solte para enviar, tr: Yüklemek için bırakın, zh-CN: 释放以上传, zh-TW: 釋放以上傳); existingfileManager.dropFilesHerereused. Parity 5240 leaves × 11 green, no English fallback. Tests. 13 new cases insrc/__tests__/hooks/usePageFileDrop.test.tsxcovering: overlay on dragenter, non-file payload ignored, child-element dragLeave keeps overlay (relatedTarget inside wrapper), outside-element dragLeave hides it, null relatedTarget hides it (cursor left window), document drop / dragend / Escape all reset (the three cancel paths the prior inline implementation missed — see the Fixed entry), drop with mixed file types filters by extension, onRejected fires when extension filter drops everything, disabled is a no-op, overlay clears on successful drop. Existing 85 cases across ArchivesPage / FileManagerPage / FileManagerExternalFolder vitest still green. ESLint clean;npm run buildclean. - Sort Printers page by ETA (#1609, requested by forgecrafttechnologies-source) — The Printers page sort dropdown gains a fifth option, ETA, beside the existing Name / Status / Model / Location. Sorts the fleet by remaining print time so the printer that's finishing next sits at the top — the reporter's use case is staging the next job's filament ahead of time without scanning every card. Tier ordering. Tier 0 = currently printing with a known
remaining_time > 0, sorted ascending by remaining minutes (soonest first); Tier 1 = currently printing without an ETA yet (post-start_printwindow before the slicer reports total time); Tier 2 = idle / finished; Tier 3 = offline. Tiebreaker within every tier is printer name, so two printers with the same ETA — or two idle printers — stay in a stable alphabetic order. The ascending / descending direction button still applies after tiers resolve, so descending puts offline printers at the top for operators triaging the fleet for connectivity issues. Data source. The cachedremaining_time(minutes) on the per-printer status query (['printerStatus', id]) — the same field the per-card "ETA … min" label already reads from onPrintersPage.tsx:3633and the fleet-wide "next finish" badge already aggregates onPrintersPage.tsx:996. No new backend query, no new round-trip; the sort consumes data that's already in the React Query cache and updated on every WebSocket push. No grouping. Unlikestatus/model/locationsorts (which group rows under section headers), the ETA sort renders a flat list — each printer's ETA is unique so grouping would just produce a header per row. i18n. New keyprinters.sort.etatranslated in all 11 locales (en: ETA, de: Restzeit, es: Tiempo restante, fr: Temps restant, it: Tempo rimanente, ja: 残り時間, ko: 남은 시간, pt-BR: Tempo restante, tr: Kalan süre, zh-CN: 剩余时间, zh-TW: 剩餘時間), no English fallback. Parity check 5239 leaves per locale, green. ESLint clean;npm run buildclean. - Prominent sponsor banner at the top of Settings → General (the default landing tab) — Full-width gradient panel with a heart icon, a one-line independence framing, and a "View supporters" CTA linking to
bambuddy.cool/sponsors.html?from=app-settingsso Matomo can split-track this surface against the website's own positions. Motivation lives inbambuddy-install-base-2026-06-20.md: re-baselining install count via the ghcr.io pull counter (~10k pulls/day rising) puts active deployments around 8-12k, and at 8 sponsors (per [[sponsor-portal]]) that's 0.08% conversion — roughly an order of magnitude under industry-benchmark for OSS with visible CTA. Matomo data confirms it's a discovery gap rather than a value-prop gap:/sponsors.htmlreaches only 1.18% of website visitors over the May 21 - Jun 19 window even though/installation.htmlreaches 29%, and the sponsors page itself converts fine when reached (70 s dwell, 53% bounce). The banner targets the in-app surface where the existing 9,140 monthly installation-page visitors actually live after they finish installing. Three newsponsors.*i18n keys (sectionTitle,tagline,viewSupporters) translated into all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Same release also ships a post-install ribbon between Quick Install and System Requirements on thebambuddy-websiterepo'sinstallation.html, plus a?from=install-bottomtracking param on the existing bottom CTA so the two website positions are A/B-comparable in Matomo from day one. - In-app sponsor-toast triggered at earned milestones (Prints / Cost / Archives / Anniversary / Version-update) — Companion piece to the prominent sponsor banner that shipped earlier in this release (Settings → General full-width gradient panel). Banner gives passive every-visit visibility on a single page; the toast adds opt-out-able active visibility at moments where the user has just earned something with Bambuddy. Motivation: 0.08% conversion gap. Re-baselining the install base from the ghcr.io pull counter (~10,000 pulls/day rising, captured in
bambuddy-install-base-2026-06-20.md) places active installs around 8,000-12,000 — at 8 current sponsors (per [[sponsor-portal]]) that's a 0.08% conversion rate, 6-25× under industry-benchmark for OSS with visible CTA. Matomo data over the May 21 - Jun 19 window shows only 1.18% of website visitors reach/sponsors.htmldespite 29% hitting/installation.html— the ask was discoverable on the marketing site but invisible inside the running app where users actually live. Trigger families (5). Prints: completed prints reach 100 / 500 / 1000 / 2500 / 5000. Cost: cumulative tracked filament-plus-energy cost crosses €100 / €500 / €1000 (currency-agnostic threshold — the frontend renders with the user's configured currency symbol). Archives: 50 / 250 / 1000 print archives saved. Anniversary: 1 year from the user'screated_at(auth-enabled), orMIN(users.created_at)as the install-anchor (auth-disabled, see below). Version-update: soft fallback that fires once after a major-version bump, re-armable on each subsequent bump. Priority order. When multiple families are eligible at once, the service picks in this order: anniversary → prints → archives → cost → version-update — most emotional / earned first; version-update is the unobtrusive fallback. 14-day cooldown across all families so an active power-week with stacked milestones never triggers more than once. Backed by a singlelast_shown_atcolumn on the per-user state row. Auth-disabled mode is first-class, not an afterthought. Roughly 60-70% of installs run with auth disabled (single-user home setups — exactly the local-first cohort that "Bambuddy stays free because people support it" lands hardest with). Rather than ship a half-feature for them, the state schema uses auser_id NULLABLEcolumn: in auth-enabled mode there's one row per real user; in auth-disabled mode there's a single NULL-keyed install-default row. The service evaluates exactly one code path that branches at the SQLWHERElevel (column IS NULLvscolumn = X), no doubled storage logic, no duplicated trigger code. Counter queries for prints / cost / archives useprint_log.created_by_id IS NULLfor the install-default count. Backend. NewSponsorToastStatemodel (backend/app/models/sponsor_toast_state.py) with columnsuser_id(nullable FK withON DELETE CASCADEso a deleted user takes their toast state with them),last_shown_at,milestones_seen(Text storing a JSON-serialisedlist[str]of fired milestone keys for SQLite/Postgres uniformity),last_seen_version, plus standardcreated_at/updated_attimestamps. UNIQUE constraint onuser_idso there can be at most one row per user (or exactly one NULL-keyed row). The table is created viaBase.metadata.create_all()at init — no explicit migration inrun_migrations()needed since this is a brand-new table, not an ALTER on an existing one. Service.backend/app/services/sponsor_prompt.pywith two public entry points:evaluate(db, user_id_or_None) -> Trigger | Nonewalks the five checks in priority order, returns the first eligible one or None;dismiss(db, user_id_or_None, milestone)anchors the 14-day cooldown and either appends the milestone tomilestones_seen(one-shot families) or just bumpslast_seen_version(version-update is re-armable). State row is created lazily on first access so no migration seed is required. Print-milestone selection picks the LARGEST unseen threshold the user has crossed — a user who reaches 600 prints with no prior toasts gets prints-500 (not prints-100), so the relevant milestone fires; if they've already seen prints-500, they'd fall through to prints-100 next time the cooldown lifts. Cost path sumsprint_log.cost + print_log.energy_costso the threshold reflects total spend Bambuddy has tracked, not just material. Routes.GET /api/v1/sponsor-prompt/checkreturns{show: false}or{show: true, milestone, family, threshold, payload};POST /api/v1/sponsor-prompt/dismisstakes{milestone: string}and returns 204. Both gated withPermission.SETTINGS_READviaRequirePermissionIfAuthEnabled— every authenticated user has this, and auth-disabled installs hit them withcurrent_user = Noneand the service handles that as the install-default row. Frontend hook. NewuseSponsorPrompt(currencyCode)hook (frontend/src/hooks/useSponsorPrompt.ts) fires once per browser session after auth resolves: checkssessionStorage['sponsorPromptShown']to avoid double-firing on a single session's mount/unmount cycles (Layout re-renders, navigation, etc.), then callssponsorPromptApi.check(). If a trigger comes back, builds the localised message via the newsponsors.toast*keys and displays a persistent toast with a "View supporters" CTA linking tohttps://bambuddy.cool/sponsors.html?from=app-toast-{milestone}— every milestone gets its own tracking parameter so Matomo can split-test which trigger families drive the most conversion. Click on the CTA firessponsorPromptApi.dismiss(milestone)to anchor the cooldown server-side and closes the toast. The hook is wired intoLayout.tsx(which sits inside<ProtectedRoute>so auth has already resolved) and pullssettings.currencyfrom the existing settings useQuery — no duplicate fetch. Toast extension. ExistingToastContextextended with optionalaction: { label, href, onClick }onshowPersistentToast. The non-dispatch toast renderer gets a new branch: ifactionis present, render an inline<a>styled as a small bambu-green pill before the dismiss-X. Click on the action fires itsonClick(used by the sponsor hook to call dismiss) and closes the toast. Existing showToast / showPersistentToast call sites are unaffected —actionis optional, omitting it gives the previous icon + message + X behaviour exactly. i18n. 5 templated keys in the existingsponsors.*namespace (toastPrints{{count}}/toastCost{{total}}/toastArchives{{count}}/toastAnniversary/toastVersionUpdate{{version}}) — fewer raw strings than naive per-milestone (5 × 5 + 3 + 3 + 1 + 1 = 25) but emotionally equivalent because i18next interpolates the count at render time. Real translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); no English fallback. Parity check 5214 leaves per locale. Cost messages are written so the currency symbol can be prepended client-side ({{total}}already includes the symbol) — works for USD, EUR, GBP, JPY, etc., the existinggetCurrencySymbolutil returns the right glyph fromsettings.currency. What this does NOT do. Provide an in-app opt-out toggle — the 14-day cooldown plus the "earned milestone" requirement means a typical user sees the toast 5-15 times per year, which we picked deliberately as the line between visible and naggy. If user feedback after the 2026-06-27 Matomo conversion check (see the install-base memory) shows the cadence is too aggressive we'll add a Settings → Notifications toggle then; shipping it now would dilute the "is this actually a problem worth fixing?" signal. Use plural-form i18n suffixes (_one,_other) on the count keys — the message templates are written so they read naturally at every count value (100 / 500 / 1000 are all plural in every locale, anniversary is hardcoded to "one year"), but languages with three+ plural forms (Russian, Polish, Arabic) would need this later if we ship those locales. Affect the existing Settings → General sponsor banner shipped earlier in this release — that's a passive every-visit surface and stays exactly as-is; the toast is the active milestone-based companion. Affect un-authenticated routes (login page, setup page, spoolbuddy kiosk, camera embeds) — the hook lives inside Layout which only renders inside<ProtectedRoute>. Tests. 24 new cases. 20 inbackend/tests/unit/test_sponsor_prompt_service.pycovering: empty-state no-fire (× 2), state row lazy creation, 14-day cooldown (within / past × 2), prints fires at 100 + picks-highest-unseen + skips-already-seen + failed-prints-don't-count (× 4), archives at 50, cost crosses 100 (counting only completed cost-bearing prints, not raw print count), anniversary at 370d vs 300d (× 2), version-update first-read silently anchors vs subsequent fires on bump (× 2), priority anniversary-beats-prints + prints-beats-archives (× 2), dismiss-adds-to-seen-and-anchors-cooldown + re-evaluation-returns-None, version-update-dismiss-updates-version-not-seen-list (× 2), auth-disabled uses install-anchor + null-keyed-counters-isolated-from-per-user (× 2). 4 inbackend/tests/integration/test_sponsor_prompt_api.pycovering:/checkreturns{show: false}on empty install,/dismiss422 on missing milestone,/dismiss204 on success, check-then-dismiss-then-recheck-is-silent (cooldown anchors even when the original check returnedshow: false). Frontend: existingToastContext.test.tsx,Layout.test.tsx,SettingsPage.test.tsxall green (74/74 — the action-prop extension is additive on an optional field, so existing toast tests with no action keep their previous expectations). Full backendpytest -n 306250/6250 in 64 s; ruff clean (4 import-order auto-fixes applied); ESLint clean;npm run buildclean (1.74 s); i18n parity 5214 × 11 green. - QR code on API-key creation that encodes server URL + key together (#1677, contributed by bambuman) — The "API Key Created Successfully" panel gets a new QR code button next to Dismiss. Clicking it opens a modal showing a single QR encoding the Bambuddy base URL and the freshly-created API key together, so a mobile client (e.g. the contributor's BambuMan NFC inventory app, or any future Bambuddy-aware app) can scan once to configure both — no copy-paste of the long, shown-only-once secret. Payload contract (versioned):
bambuddy://config?v=1&url=<encodeURIComponent(baseUrl)>&key=<encodeURIComponent(apiKey)>.v=1first so future bumps tov=2have a clean deprecation path; both values URL-encoded so reserved characters in either don't corrupt the parse. The builder lives infrontend/src/utils/apiKeyQr.tsexportingbuildApiKeyQrPayload()+API_KEY_QR_VERSIONso any future mobile-side parser has a stable shared constant to anchor against.baseUrlsource: prefers the configured External URL setting (Settings → Network), falling back towindow.location.originif not set, so the encoded address is reachable from a phone behind a reverse proxy / Docker host. The fallback's failure mode (admin onhttp://localhost:8000without External URL configured → phone can't reach the encoded URL) is unavoidable without exposing a network probe; the warning text in the modal cautions the user generally. Security posture: the QR is generated client-side from the in-memorycreatedAPIKeyReact state — the key is never persisted, never re-fetched (keys are stored hashed at/api/keysPOST and returned in plaintext exactly once), and never round-trips to the server. No download button (intentional contrast with the existingQRCodeModal.tsx, which encodes a public archive URL and does offer download) so the secret can't be saved to disk via the browser's download manager. The "Dismiss" handler now clears bothshowApiKeyQRandcreatedAPIKeyso closing the panel scrubs the plaintext from React state. Modal closes on Escape and backdrop click; an amber warning under the QR reminds the user not to screenshot or share. Component: newfrontend/src/components/ApiKeyQRCodeModal.tsxusingqrcode.react'sQRCodeSVGat 256 px (renders Version 5 / 6 territory for the typical ~120-character payload, comfortably below the alphanumeric capacity). Dependency:qrcode.react ^4.2.0added tofrontend/package.json(+21 KB raw / ~9 KB gzip to the bundle). Existingfrontend/src/components/QRCodeModal.tsxis untouched — different purpose (server-rendered PNG for archive deeplinks), different component, no collision. Tests:frontend/src/__tests__/utils/apiKeyQr.test.tspins the contract — scheme +v=first, exact encoding ofhttps://printer.local+bb_abc123byte-for-byte, special-character round-trip (+,/,=,&, spaces), explicit assertion that the raw unencoded key never leaks into the payload, and aURLSearchParamsround-trip that re-parsesv/url/keyback out and asserts equality with the inputs. 4/4 green. i18n: 4 new keys in thesettings.*namespace (apiKeyQrButton,apiKeyQrTitle,apiKeyQrCaption,apiKeyQrWarning); full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity check green. ESLint clean;npm run buildclean (7,603 kB raw, +21 kB vs dev). No backend change, no permission change, no DB migration. - Centralised sidebar layout + per-page hide toggles (#1673, contributed by EdwardChamberlain) — Sidebar item ordering and visibility move from inline
Layout.tsxstate to a dedicated module so the same persistence rules apply whether the user is reordering with drag-and-drop, toggling an item off, or accepting the admin-pushed default. Newfrontend/src/utils/sidebarLayout.tsowns the localStorage round-trip (sidebarOrder+sidebarHiddenSystemItemskeys), theSIDEBAR_LAYOUT_CHANGED_EVENTcross-tab refresh broadcast, and theisExternalSidebarItemIdhelper that distinguishes the newext-*external link prefix from built-in nav. Hide / show toggle: every built-in sidebar entry (Printers / Inventory / Archives / Queue / Projects / File Manager / Makerworld / Profiles / Maintenance / Statistics — Settings is intentionally non-hideable) now carries an eye icon in the Sidebar settings card; click it to drop that entry from the rendered sidebar. Hidden IDs persist per-user via localStorage so personal taste survives reloads without leaking to other users on a shared install. Re-show by clicking the eye again. The previous drag-to-reorder UX is retired in this PR — the hide list + admin default order cover the same "I never use the Stats page" / "give me Files first" needs without the affordance ambiguity of the rearrange handle. Admin default order: newdefault_sidebar_ordersetting (validated server-side atbackend/app/schemas/settings.py:533+) holds a JSON object{order: string[], hiddenSystemItemIds: string[]}that admins set once from Settings → General → Sidebar (Set Default toggle). On first login per user,Layout.tsx'suseEffectreads the admin default, filters it against the currentdefaultNavItems+ valid external IDs (so a deleted external link or a removed built-in doesn't strand in someone's stored order), applies it locally, and records a per-usersidebarDefaultApplied_<user_id>localStorage flag so the default is one-shot — later user-driven changes aren't clobbered on every login. Settings card:ExternalLinksSettings.tsxis the single source of truth for the Sidebar card (card-sidebar-links) in Settings → General. The header now carries the Set Default toggle (visible only when the caller holdssettings:write), a Reset button (clears bothsidebarOrder+sidebarHiddenSystemItemsto defaults), and the Add Link button (opens the external-link create modal). The body lists every sidebar item — built-in or external — with the eye toggle inline on each row. The header row usesflex-wrapon the outer container and the right-side control group so the Add Link button doesn't overflow the card's right edge when Column 3 sits at its narrowlg:max-w-sm(384px) width. Settings → General reordering (post-merge polish): the Updates card moved to the top of Column 3 (above the new Sidebar card); the Data Management card moved to the bottom of Column 2 (after Library Auto-Purge) so the General tab balances better with the new Sidebar card taking column 3's vertical real estate. Anchor IDscard-updates,card-data,card-sidebar-linksare preserved so deep-links + the in-appregisterSettingsSearchindex still resolve. Layout merge edge case: the PR's refactor ofLayout.tsx::isHiddenaccidentally dropped the dev-side notifications gate (!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || settings?.user_notifications_enabled === false) and itsadvancedAuthStatususeQuery. The merged shape keeps three gates in priority order —hiddenSystemItemIds.includes(id)first (cheapest, explicit user intent), then the array-awarenavPermissionscheck from #1755 (granular*:read_own/*:read_alltiers), then the notifications-specific gate — so a user without advanced auth doesn't suddenly see the Notifications entry. Backend:default_sidebar_ordersettings field accepts both shapes (plain array OR{order, hiddenSystemItemIds}object) for backward compat with installs that saved an array under an earlier draft of this work. Validator rejects anyhiddenSystemItemIdsthat isn't alist[str]with 422. Tests: 17 new backend cases intest_sidebar_settings.pypinning the validator (empty / JSON-array / JSON-object / mixed-types / hostile shapes). Frontend: 5 newLayout.test.tsxcases pinning the hide-toggle behaviour (hidden ID drops the entry, hidden ID for Settings is ignored —settingsis non-hideable, eye-click round-trips through localStorage,SIDEBAR_LAYOUT_CHANGED_EVENTtriggers a re-read across tabs) and 255 added/changed lines inSettingsPage.test.tsxcovering the admin-default toggle and the eye-icon visibility column. i18n: new keys in theexternalLinks.*namespace (sidebarLayout / sidebarLayoutDescription / visibleInSidebar / hiddenFromSidebar / requiredInSidebar / setDefault / etc.), full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5168 leaves per locale. Vitest test timeout raised invitest.config.tsto absorb theuserEvent.setup({delay: null})cases in the heavierSettingsPageflows. Full vitest run green; ESLint clean;npm run buildclean; ruff clean. - Structured storage locations catalog (#1505 closing #1004, contributed by Poltavtcev) — Inventory gets a first-class catalog of physical storage spots (shelves, drawers, dryboxes) instead of free-text in the spool's
storage_locationfield. Spools now carry alocation_idFK alongside the denormalizedstorage_locationstring (kept for Spoolman wire format + label rendering). The Inventory page picks up a Locations button that opens an in-page modal — the original PR landed a standalone/inventory/locationspage; merged shape is a modal opened from Inventory so the catalog read sits next to the spool list. The modal handles create / edit / delete / pick-to-filter; row-click pushes the location_id into the Inventory filter state without a navigation. Deep-link?location_id=<n>(and?location_id=__none__for the unset bucket) still works for sharing or bookmarking. Backend: newLocationmodel +locationstable with case-insensitivename_key(LOWER(TRIM(name))) UNIQUE — concurrent creates on the same name resolve to a single 409 via theIntegrityError→ re-fetch shape in_create_location_or_get_existing. CRUD at/api/v1/inventory/locations, all five routes gated withRequirePermissionIfAuthEnabled(Permission.INVENTORY_READ|UPDATE). Delete is blocked whilespool_count > 0so the user can't strand spools. Single-write-path islocation_service::resolve_spool_location_fields()— both the internal-mode and Spoolman-mode spool routes feed through it solocation_idandstorage_locationcan never drift. Spoolman parity: location names sync into the local catalog onGET /spoolman/inventory/spoolsviamaybe_sync_spoolman_locations; rename cascades to every Spoolman spool viaclient.rename_location, with a per-spool PATCH fallback when the upstream's bulk endpoint isn't there (Spoolman <0.16 doesn't exposePATCH /location/{name}and returns 404/405).get_distinct_locationsnormalises both the olderlist[str]and the newerlist[dict]Spoolman payload shapes. Migration: inline indatabase.py::run_migrations— creates thelocationstable (DATETIME for SQLite / TIMESTAMP for Postgres), addsspool.location_idFK + index, then backfills the catalog from existing free-text values (GROUP BYLOWER(TRIM(storage_location))so case variants likeDrybox 1andDRYBOX 1collapse into one row). The legacyname_keybackfill runs BEFORE the dedup INSERT so a pre-existing locations row with NULLname_key(manually inserted before this feature shipped) gets its column populated first and the subsequent spool-link UPDATE can join on it. Post-migration warn-log flags any spools that still carry free-textstorage_locationwith nolocation_id— surfaces the rare mis-link case to ops instead of silently leaving them out of catalog filters. Rename safety: Spoolman PATCH runs BEFOREdb.commit(), cascade failure rolls back the local rename and raises HTTP 502 — without this ordering a partial failure left the catalog and Spoolman's per-spoollocationfield permanently diverged (the next sync recreates the old name as a duplicate catalog row). Legacy-row UPDATE matchesfunc.lower(func.trim(Spool.storage_location)) == old_name.strip().lower()so the SQL TRIM symmetry holds for whitespace-padded values. Cross-tab refresh:spoolman_inventory.pynow emitsinventory_changedon the 8 spool-mutating routes (create, bulk-create, update, delete, archive, restore, reset-bulk, weight, tag) — internal mode already broadcast in 12 places, Spoolman mode silently degraded before. TheuseWebSockethandler invalidatesinventoryLocationsQueryKeyon every such message so location counts stay in sync across tabs. Performance: the Spoolman→catalog sync used to fire on everyGET /spoolsrequest, hit Spoolman, and open a write transaction; now guarded by a 60s per-URL TTL cache (_spoolman_location_sync_last_run) so a polling UI doesn't burn a Spoolman round-trip + SQLite write per refetch. The route also passes its already-resolved client through to the sync so test fixtures that patch the route module's client also catch the sync's client lookup — without this the SSRF LAN-topology parametrize tests took ~45s on real TCP timeouts to RFC-1918 IPs (now 2.79s in isolation). Frontend:SpoolFormModallocation dropdown sendslocation_idonly (same shape in both inventory modes — nospoolmanMode ? ... : ...UI gate) and theonCreateLocationflow surfacesApiError.messageinstead of a generic toast so 409 / 400 / 500 stay distinguishable.LocationsModalpassesisLoadingtoConfirmModalduring delete so a mid-mutation cancel can't strand a toast on a dismissed dialog; Pencil / Trash icon buttons carryaria-labelfor SR announcement. i18n: newlocations.*namespace (20 keys: title, subtitle, add, edit, delete, empty, name, spools, manage, createPlaceholder, nameRequired, created, updated, deleted, saveFailed, deleteFailed, deleteBlocked, confirmDelete, confirmDeleteMessage, editAria/deleteAria), full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5168 leaves per locale. Tests: ~26 new acrossbackend/tests/unit/test_location_service.py(rename strip/lower symmetry, sync-from-Spoolman log-on-unavailable, list[dict] payload normalisation),backend/tests/unit/test_spoolman_inventory_methods.py(get_distinct_locationsshape guard × 4,rename_locationbulk-then-fallback × 4 — 200 / 404 / 405 / 5xx),backend/tests/unit/test_location_migration.py(NULL + whitespace-only storage_location skip, legacy NULL name_key ordering, case-variant dedup, idempotency),backend/tests/integration/test_locations_api.py(CRUD round-trip, rename cascade, IntegrityError → 409, PATCH/DELETE 404, auth-gate 401 on all five routes whenauth_enabled=true), andfrontend/src/__tests__/components/LocationsModal.test.tsx(12 cases: open=false renders nothing + no fetch, row click → onPickLocation + onClose, 2-level Escape dialog stacking, rename collision 409 toast, disabled delete onspool_count>0, etc.). FrontenduseWebSocket.test.tsexercises theinventory_changed→ invalidate['inventory-locations']round-trip. Full backend pytest 6025/6025 (67s with -n 30); frontend vitest 2141/2141; ruff clean;npm run buildclean; ESLint clean; i18n parity green. - Admin-configurable session lifetime (#1706, reported by AD3DStuff) — The 24-hour session cap that ships with Bambuddy was an intentional security hardening (audit finding M-2 reduced it from 7 days), but the "Remember Me" checkbox only controlled storage location (localStorage vs sessionStorage), not session duration. iPhone PWA users and homelab admins on trusted networks were getting kicked out every 24 hours with no way to extend it. New setting:
session_max_hoursunder Settings → Users with three presets (24h / 7 days / 30 days) plus a custom field, hard-capped at 30 days (720h). Default remains 24h so existing deployments and the M-2 audit baseline are untouched until an admin opts in. The Settings card surfaces a yellow warning whenever the value exceeds 24h: "Longer sessions reduce automatic logout protection. Recommended only for trusted single-user deployments." Backend wiring: newresolve_session_max_minutes(db)helper inbackend/app/core/auth.pyreads the setting, clamps to [1h, 720h], and falls back to 24h on missing / blank / unparseable values. The helper is called at all four token-issuance sites — plain/auth/login, 2FA TOTP/email completion, 2FA backup-code completion, and OIDC callback — so a long-session policy works uniformly regardless of how the user authenticates. DB errors in the resolver are deliberately NOT caught: login is already inside a transaction and a broken DB must abort the login rather than silently extend or shrink the session lifetime. Defense-in-depthSESSION_MAX_HOURS_HARD_CEILING = 720clamps any tampered DB row above the Pydantic ceiling. Already-issued tokens keep their original expiry — the new setting only affects future logins, so an admin lowering the value can't retroactively revoke active sessions and an admin raising it can't retroactively extend them. What this does NOT change: the "Remember Me" checkbox still controls only storage location (cleared on browser close vs persisted across restarts). The relabel from misleading-UX-perspective is left for a separate follow-up — that's a UX choice independent of the session-policy mechanism. API tokens (MAX_TOKEN_LIFETIME_DAYS), camera stream tokens (60min), WebSocket tokens (60min), and slicer download tokens (5min) keep their own TTLs and are unaffected. Tests: 15 new cases inbackend/tests/integration/test_session_policy.pysplit across three classes.TestResolveSessionMaxMinutespins the clamping resolver — missing row, empty string, unparseable value, zero/negative, 1h minimum, 7-day passthrough, 30-day passthrough, above-ceiling clamp.TestLoginRespectsSessionPolicydecodes the JWTexpclaim end-to-end and asserts the token returned by/auth/loginhonours the configured ceiling for the default-24h, configured-7d, and above-ceiling-clamp cases.TestSettingsAPIExposesSessionMaxHoursround-trips the field through/settings/(default = 24, valid update persists as int's string form, zero rejected with 422, above-ceiling rejected with 422). Existing 202-case auth + MFA suite still green. i18n: 8 new keys insettings.sessionPolicy.*namespace; full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity check 5149 leaves per locale. ESLint clean;npm run buildclean; ruff clean. - Per-VP "G-code injection" toggle for Studio Send / FTP uploads (#1516, contributed by phieb) — Queue-mode Virtual Printers gain a per-VP opt-in toggle that applies the Settings → G-code Snippets per-model start/end snippets to every job that lands via the VP — Bambu Studio's "Send", OrcaSlicer's "Print Plate", the VP's own FTP upload path. Before this change the snippets were only applied to items queued through the PrintModal's "Inject auto-print G-code" checkbox; VP-incoming jobs silently bypassed injection regardless of how the snippets were configured. Default off so upgraders don't silently start injecting: existing
gcode_snippetsinstalls keep their previous behaviour until the per-VP toggle is explicitly enabled. When on, the scheduler still no-ops unlessgcode_snippetsare configured for the target printer model, so the effective semantics are "inject when enabled AND snippets exist." DB column: newvirtual_printers.gcode_injection BOOLEAN DEFAULT FALSEwith a branchedis_sqlite()migration (SQLiteDEFAULT 0/ PostgresDEFAULT FALSE) matching thequeue_force_color_match/tailscale_disabledprecedent. Multi-plate stamping: the flag is set on every plate'sPrintQueueIteminside the per-plate loop introduced by #1697 / #1188, so a multi-plate "Send all" upload now gets snippets injected on each plate consistently — the original PR only stamped the first plate; the merge resolution wove the flag into the loop. Live-toggle correctness: the_sync_from_db_lockedchange detector now comparesinstance.gcode_injection != vp.gcode_injection, so toggling the value in the UI triggers a VP restart instead of letting the in-memory instance keep the stale flag and silently propagate it onto every subsequent upload — same shape as the #1552 family. Backed by a dedicatedtest_sync_from_db_restarts_on_gcode_injection_toggle. UI: new toggle onVirtualPrinterCard.tsx(queue mode only — the toggle is hidden in archive/review/proxy modes since the feature is queue-specific), with the standardupdateMutationsave-on-click + toast on success, plus thependingAction='gcodeInjection'opacity dim during the round-trip. PrintModal hardening: when "Inject auto-print G-code" is ticked on a reprint at quantity > 1, the modal now routes ALL copies through the queue (not just copies 2..N) so the scheduler injects every dispatch — see the separate reprint-quantity entry below for the full motivation. A newuseEffectclears the stalegcodeInjectionstate if the user ticks the box at quantity 2, then drops back to quantity 1 — the checkbox hides at that point and the state must follow, otherwise the immediate-reprint path would silently bypass injection. Diagnostics: the resolved start/end snippets (with{placeholder}substitution already applied) are logged at DEBUG so any "snippet didn't run" report can be traced from a log bundle. i18n: newvirtualPrinter.gcodeInjection.title+descriptionkeys translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); parity check 5188 leaves per locale, no English fallback. Tests: 2 new unit cases intest_virtual_printer.py(queue items opt in / out based on the VP flag), 2 new integration cases intest_virtual_printer_api.py(create defaults to false, PUT round-trips the value), 1 new sync-restart case, plus updates to_make_db_vpso the change-detector test fixture carries an explicitFalserather than relying onMagicMocktruthiness. 2 new PrintModal vitest cases pin the reprint dispatch matrix (injection ON queues all copies and dispatches none immediately; injection OFF keeps the immediate first copy and queues the rest). Full backend pytest 6167/6167; full frontend vitest 2154/2154; ruff clean;npm run buildclean. - Heater history (nozzle / bed / chamber) tracked + charted, matching AMS sensor history — Bambuddy already logged AMS humidity / temperature every 5 minutes and exposed them in a per-AMS history modal; the printer-side heaters (nozzle, optional second nozzle on H2D, bed, chamber) had no equivalent surface. New per-printer recorder logs heater readings every 60 s (heaters move faster than AMS humidity) into a new
printer_sensor_historytable — long format per(printer_id, sensor_kind, value, target, recorded_at)withsensor_kind ∈ {nozzle, nozzle_2, bed, chamber}so model variants (single vs dual nozzle, sensor-only X1C/P2S chamber vs heater-equipped X1E/H2D chamber) all fit without a wide-row migration when new sensors get added later. Recorder source. Pulls fromstate.temperatures(already normalised across model field-aliases likenozzle_temper,left_nozzle_temper,right_nozzle_temper,chamber_temperby the MQTT parser) rather than re-parsingraw_data, so cross-model coverage is free. Skips disconnected printers; absent sensors (no chamber on A1 / A1 Mini) just don't produce rows for that kind. Retention. Newprinter_sensor_history_retention_dayssetting (default 30, mirroring the existingams_history_retention_daysknob); periodic cleanup fires once every ~24 h within the recorder loop and trims rows older than the configured cutoff. API.GET /printer-sensor-history/{printer_id}?hours=<1-168>&kinds=<csv>returns one series per requested kind withdata: [{recorded_at, value, target}, ...]+ min / max / avg stats per series.DELETE /printer-sensor-history/{printer_id}?days=<N>for explicit purge. Both gated behind the new explicitPRINTER_SENSOR_HISTORY_READscope (separate fromAMS_HISTORY_READso admins can grant heater stats without granting humidity stats and vice versa — both default into the Operators + Viewers groups, mirroring the existing AMS gate). UI surface — tiny chart icon on each heater tile. Each heater tile in the printer card (nozzle / bed / chamber) gets a 10×10 px lucideLineCharticon button in its top-right corner. Click on the tile body still opens the existing target-temp control popover (unchanged); click on the icon opens the newHeaterHistoryModalwith that kind pre-selected ande.stopPropagation()so the underlying control popover doesn't also fire. The read-only chamber tile (X1C / X1E / P2S — sensor-only, no M141 acceptance) gets the icon overlay too, so for the first time it has a useful interaction beyond just showing the reading. Modal shape. Samevar(--bg-*)/var(--text-*)theme variables asAMSHistoryModalso it follows every background variant (neutral / warm / cool / oled / slate / forest), not just light vs dark. Kind toggle (nozzle/nozzle_2/bed/chamber, filtered by what's actually available on this printer) at top-left, time range (6h / 24h / 48h / 7d) at top-right, four stat cards (current with trend arrow + target value, average, min, max), and a rechartsLineChartplotting current value as a solid line plus target as a dashed step-after line so you can see where the printer was tracking vs where it was asked to be. Empty / loading / error states all localised. i18n. 8 new keys inprinters.heaterHistory.*(title,openLabel,nozzle,nozzle2,bed,chamber,error,empty) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); parity check 5206 leaves per locale, no English fallback. Tests. 4 new backend cases intest_printer_sensor_history.py(per-sensor series + stats;kindsquery filter;hourswindow clamp; DELETE removes only the targeted printer's old rows). 6 new frontend cases inHeaterHistoryModal.test.tsx(closed → renders nothing; open → title + printer name; current value from last point; switch kind with button; empty-series state; close button onClose). Full backendpytest -n 306226/6226 in 92 s; full frontend vitest 2176/2176; ruff clean;npm run buildclean; ESLint clean. - AMS Filament Backup status + control on the printer card — New per-printer surface that mirrors BambuStudio's "AMS Filament Backup" checkbox (the per-AMS auto-switch to a second matching spool when one runs out). Until now Bambuddy had no read or write access to the printer-side backup state; the only way to change it was via the slicer or the printer's touchscreen, and Bambuddy's "Prefer lowest remaining filament" preference was ignorant of it (see the linked Fixed entry for #1766 — the two ship together). Backend — parse the state. New tri-state
PrinterState.ams_filament_backup: bool | Nonepopulated from bit 18 of the top-levelprint.cfghex string on every push_status (bambu_mqtt.py::_process_message~line 1037). New module-level helperparse_ams_filament_backup_from_cfg()returnsNoneon absent / non-hex / non-string input so old-protocol families (A1 / A1 Mini, which emit nocfg) preserve today's behaviour — the tri-state default applies the dispatcher's sort, never coerces to OFF, so A1 users see zero regression. Verified against OrcaSlicer source (DeviceManager.cpp:4961SetAutoRefillEnabled(get_flag_bits(cfg, 18))) and a live H2D ON/OFF capture during this work — the cfg flips exactly betweenC0340FC219(bit 18 set, ON) andC0340BC219(bit 18 clear, OFF), only the fifth nibble changing. Backend — toggle. NewPOST /printers/{id}/ams-backup?enabled=<bool>route gated onPermission.PRINTERS_CONTROLcallsclient.set_ams_filament_backup(enabled)which routes through_set_print_option("auto_switch_filament", enabled). The MQTT payload shape{"print": {"command": "print_option", "auto_switch_filament": <bool>, "sequence_id": "20000"}}was verified by capturing BambuStudio's own command on the request topic with a temporary outbound diagnostic logger — single field at a time, never bundled with otherprint_optionflags, so we never clobber other state. Optimistic local state update lives inside_set_print_optionimmediately after_client.publish(...). Hold-timer guard (_xcam_hold_start["print_option_auto_switch_filament"], 3 s window, mirrors the existing xcam pattern for spaghetti / first-layer detector settings): when the user just toggled via Bambuddy's badge, the next 1-2 push_status frames may still carry the printer's PRE-toggle cfg before the firmware reflects the change — without this gate the badge would flicker ON→OFF→ON on every toggle. The hold fires only when Bambuddy itself initiated the change; Studio-side or printer-display toggles propagate immediately. Backend — inventory-remain endpoint. NewGET /printers/{id}/inventory-remainroute exposes the sameMap<global_tray_id, grams>the dispatcher uses (via the existing_build_inventory_remain_overrideshelper), so PrintModal's client-side "Prefer Lowest Remaining Filament" sort can apply the same two-tier ordering the backend would on dispatch. Internal AND Spoolman modes both work uniformly via the existing helper's mode branch — external / VT slots excluded, negative grams clamped tomax(0.0, label - used). JSON-keyed-as-string convention so the wire format is clean; client coerces back to Number on receive. Permission:Permission.PRINTERS_READ(same as reading printer status). REST + WS response surface.printer_state_to_dictand thePrinterStatusResponsePydantic schema both extended with the new field; the printer's REST/printers/{id}response carriesams_filament_backup.state.ams_filament_backupadded to thestatus_keydedup tuple inmain.py:1101so backup toggles trigger an immediate WS broadcast and clients see live state changes whether the toggle came from Bambuddy, BambuStudio, or the printer's touchscreen. Frontend — printer card badge. Small icon button in the "Filaments" section header on each printer card (PrintersPage.tsx), placed beside the section label so the printer-wide nature reads correctly (the cfg bit is one per printer, not per AMS unit — the original draft put it per-AMS row, which would have duplicated the same state on multi-AMS printers and looked confusing). Three states: ON = blue circular-arrow icon (Repeatfrom lucide-react) onbg-blue-500/20; OFF = dim icon onbg-bambu-dark; unknown (A1 family / no cfg yet) = "?" character on dim background, click disabled. Click on a known state toggles via the new endpoint, with optimistic update and success toast (AMS Filament Backup enabled/disabled). The mutation invalidates BOTH'printerStatus'(camelCase) and'printer-status'(kebab-case) cache keys — the codebase has both conventions in active use (useFilamentMapping-related hooks use kebab, everything else uses camelCase), so only hitting one would leave PrintModal showing stale backup state if the user toggled from the printer card while the modal was open. i18n. 5 new keys in theprinters.amsBackup.*namespace (titleOn,titleOff,titleUnknown,toastEnabled,toastDisabled) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Tests. 12 new backend cases —test_bambu_mqtt_cfg_parse.py(parser × 13: real H2D ON/OFF captures, X1C short hex, lowercase, isolated bit-18 set / clear, every malformed shape returns None safely — note: 1 case is a parametrized invalid-input set of 7 sub-cases so the test file shows 13 reported cases) andtest_bambu_mqtt.py::TestAmsFilamentBackupHoldTimer(× 3: stale push during hold ignored, push after hold applies, same-value push during hold no-op). Two PrinterState SimpleNamespace stubs intest_printer_offline_notification.pyandtest_printer_manager_status_broadcast.pyextended withams_filament_backup=Noneto match the newstatus_keyfield; full pytest confirms no other stub needed updating. What this does NOT do. Cover A1 / A1 Mini: those models emit nocfgfield in push_status so the badge shows?and the dispatcher's sort applies as before. Once we identify the A1-specific field (waiting on a future Discord owner with a clean ON/OFF capture) we'll populate it via a model-specific path; until then the tri-state default keeps zero regression. Affect downstream consumers of PrinterState:mqtt_relay, webhook routes, and Home Assistant integration enumerate fields explicitly, so addingams_filament_backupdoesn't change what they emit. Full backend pytest 6217/6217; full frontend vitest 2170/2170; ruff clean;npm run buildclean; ESLint clean. - Sort File Manager folder tree by recent activity (#1770, requested by Kingbuzz0) — Until now the folder tree was always sorted alphabetically by name, both backend (
order_by(LibraryFolder.name)) and frontend. The reporter — a user with a lot of nested cad / slicer directories — wanted "find folders that just got a new 3MF" without scrolling the whole alphabet. What changed. The folder sidebar header gains a small dropdown (By name / By recent activity) plus an asc / desc arrow button, sitting alongside the existing Collapse + Wrap toggles. Choice persists per-browser vialocalStorage(library-folder-sort-field,library-folder-sort-direction) so the preference survives reloads. Activity semantics.latest_activity_atper folder =MAX(folder.updated_at, MAX(immediate-child file.updated_at)). The DB had the data —LibraryFile.updated_atisonupdate=func.now()andLibraryFolder.updated_atthe same — butLibraryFolder.updated_atalone only bumps on rename / move, not on file-add inside the folder, which is exactly the wrong signal for "did I just drop a new model in here." The aggregate fixes that. Recursion across subfolders is intentionally NOT computed — a deeply nested new 3MF bubbles its immediate parent, not every ancestor up to the root. This keeps the route a singleGROUP BYrather than a recursive CTE, matching the existing file_counts subquery shape sibling atlibrary.py:746. A future Tier 3 follow-up could add the recursive-CTE variant if anyone reports deeply-nested updates not bubbling far enough. Backend. Newlatest_activity_at: datetime | Nonefield onFolderResponseandFolderTreeItemschemas. The/folderstree route picks up a siblingfunc.max(LibraryFile.updated_at)group-by alongside the existing file-count subquery; resolves the field per row. The/folders/by-project/{id}and/folders/by-archive/{id}routes collapse their per-row file-count subquery to fetchcount + maxin one trip (one extra column, zero extra round-trips). All 5 single-folder constructors (POST/folders, GET/folders/{id}, PUT/folders/{id}, POST/folders/external, the create flows) populate the field withmax(folder.updated_at, latest_file)or fall back tofolder.updated_atwhen there are no files, so the API surface is consistent across every route that returns a folder. External folders.LibraryFilerows are created for scanned external files too (library.py:526), so the MAX aggregate works on them — but the timestamp reflects when Bambuddy last scanned / re-indexed the file, not the filesystem mtime. For a NAS that gets new files added outside Bambuddy, the activity-sort lags until the next scan. Documented in the file-manager wiki page rather than papered over withos.stat()on every list call, which would stall the route on slow mounts. Frontend. A new recursivesortedFoldersuseMemoapplies the comparator uniformly to top-level + every nestedchildrenlevel so sort order is consistent at every depth. Comparator falls back to name when activity timestamps tie or are both null, so an empty folder never elbows a recently-used one to a random place — empties go to the end of the activity bucket regardless of direction. Both the desktop sidebar render and the mobile selector dropdown consumesortedFoldersso the order is identical across breakpoints. The single-folderfindFolder()traversal andselectedFoldermemo still operate on the unsortedfoldersbecause they index by ID — sort-order-independent. Recursion safety. The sort creates fresh object refs at every level on every memo invocation; theFolderTreeItemkeys stay ID-based (${folder.id}-${collapseFoldersByDefault ? 'c' : 'e'}) so React reconciliation by ID preserves folder expansion state across sort flips. i18n. 3 new keys infileManager.*(folderSort,folderSortByName,folderSortByActivity) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity 5238 leaves per locale. Tests. 2 new backend integration cases intest_library_api.py(file-in-folder bubbleslatest_activity_atto the file's timestamp, empty folder falls back tofolder.updated_at). All 152 library + folder + trash + slice integration tests still pass; 51/51 FileManagerPage frontend tests still pass; 26/26 QueuePage tests still pass;npm run buildclean;ruffclean; i18n parity green. - Drag-reorder for grouped queue items + collapsed-batch no longer blocks adjacent rows — Print queue batch grouping (multi-plate auto-batch + manual "Group as batch") shipped with an intentional v1 limitation: the batch parent row had no drag handle and wasn't registered with the SortableContext, so a batch was stuck at whatever position it was created at. Worse, a collapsed batch acted as an unmovable obstacle that adjacent standalone items couldn't drag cleanly past. The only workaround was to ungroup, reorder, and re-group. Now. Batch parents register in the SortableContext under a synthetic
batch-<id>string id, and the parent header carries a realGripVerticaldrag handle next to its existing checkbox + collapse chevron (gated byqueue:reorderlike the per-item handle).handleDragEndlearned to resolve both batch ids: when the dragged source is a batch, the moving block is all of its child items in their current order; when the drop target is a batch, the anchor is the batch's first child so the group lands immediately before it. Direction-aware insertion (dragging downward inserts after the target) uses the first moving id's index instead of the previously hard-coded single dragged id, which keeps multi-row and batch drags converging on the same insert math. Within-batch reorder of expanded children is unchanged — they're still individuallyuseSortable-registered and the per-row drag handle still works. TheDragOverlaygets a second branch for batch drags showing<batch name> (<N> copies)with a Package icon, so the user sees what they're moving. i18n. 2 new keys (queue.batch.dragGroupbutton title +queue.dragGhost.batch/..._pluraloverlay text), real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5235 leaves per locale, no English fallback. The FR plural is a legitimate cognate (French plural of "copie" is also "copies") so it's pinned inFR_COGNATESrather than fake-translated. Build + tests. Full QueuePage vitest 26/26 green;npm run buildclean; ESLint clean; i18n parity green. - Per-filament humidity threshold for auto-drying + alarms (#1605, requested by thenewguy) — The single global
ams_humidity_fairknob (default 60 %) drove both the queue / ambient auto-drying trigger AND the hourly humidity alarm — fine for a homogeneous AMS, brutal for the multi-material print farms the reporter described (one AMS dedicated to PLA fine at ~20 %, another holding Nylon that wants <10 %, ASA somewhere in between). The drying RUN parameters were already per-filament (drying_presetsJSON with PLA / PETG / TPU / ABS / ASA / PA / PC / PVA × n3f / n3s temp + hours), but the TRIGGER was a single int. New settingams_humidity_thresholds— JSON map of filament type to threshold percent, with an explicit"default"key for unknown / unmapped types. Empty / unset → both consumers fall back to the existingams_humidity_fairvalue so the upgrade is silent (no behaviour change until the user opens the editor). Mixed-AMS resolution: most-restrictive wins, matching the conservative-params strategy_get_conservative_drying_paramsalready uses for temp / hours selection. A unit loaded with PLA (threshold 60) + Nylon (threshold 20) drying-triggers + alarms at 20 — same lowest-wins logic. Empty / unloaded tray slots contribute no constraint; an entirely empty AMS falls through to thedefaultkey. Two consumer sites rewired in lockstep via a single newPrintScheduler.resolve_humidity_threshold(trays, thresholds, fallback)static method: (1)print_scheduler.py::_check_auto_drying— the per-AMS humidity comparison that decides whether to start drying, whether to stop drying after the 30-minute minimum, and whether to skip a unit entirely; (2)main.pyAMS sensor / alarm worker — the hourly notifier that fireson_ams_humidity_high/on_ams_ht_humidity_high. Both sites read the sameams_humidity_thresholdssetting through the same resolver so a config change can never make the drying scheduler and the alarm path disagree about whether a given AMS is "too humid." UI. New table in Settings → Workflow → Auto-Drying, immediately below the existing Drying Presets table — same visual idiom (filament-type column on the left, value column on the right, italicised "Default (unknown types)" row at the top so the catchall is visible, eight default rows for PLA / PETG / TPU / ABS / ASA / PA / PC / PVA pre-filled from the user's currentams_humidity_fairso the editor starts in a sensible state). Numeric inputs use a draft-on-edit / commit-on-blur pattern (transienthumidityDrafts: Record<string, string>state per row) so intermediate strings like"","3","7"aren't eaten by the[5, 95]clamp while the user is mid-typing. The clamp fires once ononBlur(andEnterblurs the input). Empty value on blur clears that row's override, letting it fall back to the default row (orams_humidity_fairfor the default row itself) — gives the user a no-mystery way to reset to default. The naive controlled-input pattern was caught mid-PR by Maziggy's typing test: typing30into a field showing60snapped to5between keystrokes becauseparseInt("3") | clamp([5, 95])resolved to5before the user finished typing the second digit. i18n. Four new keys in thesettings.*namespace (humidityThresholds,humidityThresholdsDescription,humidityThresholdCol,humidityThresholdDefault) — real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5232 leaves per locale, no English fallback. Tests. 12 new backend cases intest_scheduler_auto_drying.py::TestResolveHumidityThreshold+TestGetHumidityThresholds— empty overrides → caller fallback, single known type uses override, mixed load picks lowest, unknown type uses thedefaultkey (not the caller fallback), empty tray slots skipped, all-empty trays usedefault, "PLA Basic" / "pla basic" both normalize to PLA, missingtray_typefield is treated as empty, plus the four DB-loader cases (missing setting → empty, empty value → empty, invalid JSON → empty, valid JSON normalizes lowercase filament keys to uppercase but preserves the literaldefaultkey). One updated integration test intest_settings_ui_preferences.pypins the new field in the public_UI_PREFERENCE_FIELDSallowlist so a future regression that drops it would fail the field-set assertion. 1 new frontend case inSettingsPage.test.tsxexercises the editor render on the Workflow tab. Setting is in the public/ui-preferencesallowlist because it's a non-sensitive integer map needed for badge-color rendering on spool / AMS pages without grantingSETTINGS_READ(same rationale asdrying_presetsandams_humidity_fair). What this does NOT do. Provide per-tray override (the resolver runs at the AMS-unit granularity because both the firmware drying command + the alarm fire per AMS, not per slot); auto-detect the user's loaded filaments and pre-populate (the editor pre-fills fromams_humidity_fairand lets the user opt in to overrides); add "keep AMS heater running during print" (a separate firmware-dependent ask in the same issue — needs feasibility check with the reporter before scoping). Full backendpytest -n 306270/6270 in 73 s; ruff clean;npm run buildclean; ESLint clean; i18n parity green. - Per-printer Maintenance Mode toggle (#1476, requested by IndividualGhost1905 / Ferdi SEVER) — Operator-flipped "out of service" state per printer, surfaced as a wrench icon + amber pill on the card and a checkbox in the Edit Printer dialog. Requested for three real-world scenarios that all share the same shape: (1) parallel Bambuddy installs (dev + prod, primary + warm spare) where the printer rejects concurrent MQTT clients except one, leaving the others in a flicker-online state burning CPU and network; (2) printers under repair / awaiting spare parts that shouldn't accept queue jobs but should remain visible on the dashboard so they aren't forgotten; (3) temporary suspension during maintenance work. What was already there, what was missing. The backend field
Printer.is_active: boolhas shipped since the initial Bambuddy release — toggling it viaPATCH /printers/{id}already disconnects MQTT (printer_manager.disconnect_printeratprinters.py:366), stops the printer from being eligible for queue dispatch (print_scheduler.py:520, 1588,print_queue.py:383), excludes it from model-based filament lookups (printers.py:197), excludes it from metrics + diagnostic snapshots + scheduled-backup runs (metrics.py:105,diagnostic_snapshot.py:126,github_backup.py:333,maintenance.py:457), and is already honoured by PrinterSelector (filtered with a "show inactive" override, greyed + "(inactive)" label when shown). All three of Ferdi's use cases were structurally supported byis_activefrom day one. The missing piece was UI exposure.grep is_activeonPrintersPage.tsxreturned zero hits — no menu item, no edit field, no toggle. The only way to flip it was a direct API call. This change adds the surfaces that should have been there all along. Card UI — replacement, not addition. Per Ferdi-conversation feedback, the maintenance state replaces the print-status / cover-image container rather than stacking above it, so card heights stay identical across the grid: in expanded mode the same<Status>header renders an amber panel (wrench icon + "In Maintenance" + subtitle + Exit button) where the cover + progress would normally be; in compact mode a single amber pill replaces the progress bar. The header connection pill is also swapped — instead of the red "Offline" pill (which would be misleading because the disconnect is deliberate) the card shows an amber "Maintenance" pill, and the "Run Diagnostic" CTA is suppressed (that's reserved for involuntary offline triage). HMS / Queue / Firmware status pills are still gated bystatus?.connectedso they fall away naturally with the MQTT disconnect. Three entry points. (1) Printer card three-dot overflow menu —Enter maintenance mode/Exit maintenance modewith a wrench icon, adjacent to the Edit and Reconnect actions. (2) Exit button inside the in-card amber panel, so a user noticing the card from across the room can flip back without opening the menu. (3) Checkbox in the EditPrinterModal —Maintenance modewith the same subtitle as the help line, so the toggle is discoverable from the edit dialog too (the checkbox is the inverse ofis_activebecause the user-facing concept is "is this in maintenance" not "is it active"). Mid-print safety prompt. Entering maintenance mode on a printer inRUNNING/PAUSEstate triggers a confirmation dialog before the toggle fires — disconnecting MQTT mid-print stops progress tracking + completion notifications for the in-flight job, which is usually NOT what the operator wants (they probably meant "after this print finishes"). Idle / FINISH / FAILED states skip the dialog and toggle directly. What this does NOT change. No backend change (is_activewas already wired everywhere); no new permission (uses existingprinters:update); no behaviour change for any other consumer (queue dispatch, scheduler, metrics, picker, backup — all already honouredis_active). The card stays visible on the Printers page (greyed temps/controls/fans below the amber banner) so the printer doesn't disappear from the operator's mental map — Ferdi explicitly wanted to remember it's there. Doesn't auto-pause Smart Plug logic or notification providers (would be a sensible follow-up if Ferdi asks; out of scope here to keep the diff bounded to "expose the existing gate"). The scheduled-maintenance dashboard at/maintenance(interval-tracked rod-cleaning / lube / belt tasks via the existingMaintenanceHistoryandPrinterMaintenancemodels) is conceptually adjacent but operationally distinct — the dashboard tracks "this printer is due for cleaning"; Maintenance Mode tracks "this printer is currently out of service." A future "perform maintenance task → optionally enter maintenance mode while you do it" link is the natural connection but isn't wired here. i18n. Twelve new keys underprinters.maintenance.*(title / subtitle / pillLabel / exitButton / menuEnter / menuExit / toastEntered / toastExited / confirmMidPrintTitle / confirmMidPrintMessage / editFieldLabel / editFieldHelp) — real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5228 leaves per locale, no English fallback. Tests. 4 new cases inPrintersPage.test.tsx::'maintenance mode (#1476)': amber status panel renders with Exit button (and the regular "No active job" / "Ready to print" copy is absent — confirms the swap, not a stacked render); header pill swaps to amber Maintenance and the diagnostic CTA is suppressed; clicking Exit issues aPATCH /printers/{id}withis_active: true; active printers never show the maintenance panel. Existing test fixture (mockPrinters) got an explicitis_active: trueto keep the existing 56 tests green on the new render path. Type:PrinterCreate.is_active?: booleanadded to the TypeScript surface so the field flows cleanly through the existingapi.updatePrinterhelper. Build + checks. Full PrintersPage vitest 60/60 green;npm run buildclean; ESLint clean; i18n parity 5228 × 11 locales green.
Fixed
- Archives drag-and-drop overlay stuck after cancel (#1510, reported by maikolscripts) — Cancelling a drag on the Archives page — by dragging back out of the browser window, releasing outside the page, or pressing Escape mid-drag — left the full-screen "Drop .3mf files here" overlay visible until the user refreshed. Cause. The old inline
handleDragLeaveonly hid the overlay whene.currentTarget === e.target(i.e. the dragLeave event fired on the wrapper itself, not a child). That condition was structurally safe for crossing internal element boundaries but rarely held for the three cancel paths above — drag-out-of-window fires dragLeave withtargetat the nearest child to the cursor; Escape and drag-abort fire no leave event at all on the wrapper. Fix. Moved the page-wide drop handling into the newusePageFileDrophook (also consumed by File Manager — see the linked Added entry). The hook checksrelatedTargetcontainment instead ofcurrentTarget === target, and adds document-leveldrop/dragend/keydown(Escape)listeners that only register whileisDraggingOver === trueso the cancel paths all reset uniformly. Three of the 13 new hook test cases pin the cancel paths explicitly so a future regression on any one of them fails its own case. Also moved the previously-hardcoded English "Drop .3mf files here" string inArchivesPage.tsx:3202to the existingarchives.page.dropFilesHerei18n key (which already had translations in all 11 locales) so the overlay localises correctly — same change of behaviour asarchives.releaseToUploadalready had. - File Manager list-view column headers misaligned with their body cells — Both the header row and each list row used the same
grid-cols-[auto_1fr_120px_100px_100px_100px_min-content]template — looked correct at the CSS level — but the two<div>s were sibling grids, not a shared grid, so each computedmin-contentfor the trailing actions column independently. The header's trailing column is an empty<div />→min-contentresolved to 0; body rows had 4–7 action icons →min-contentresolved to ~220px. With different trailing widths, the1frName column got a different amount of room in each grid, which pushed every fixed column to its right (Uploaded By,Type,Size,Prints) further right in the header than in the body. Visually the body cells looked shifted left of their column headers. Fix. Replaced the trailingmin-contentwith a fixed220pxin both the auth-enabled and auth-disabled grid templates (matching the comment that already documented the expected width of the 7-icon strip on sliced 3MFs). Updated the explanatory comment with the sibling-grid pitfall so the next person doesn't re-introduce it. No tests changed; the misalignment was purely visual (no DOM ordering / interaction changed), and the existing 51 FileManagerPage tests stay green. - Mid-print AMS Backup spool-switch credited the entire print to the second spool instead of splitting the weight (#1771, reported by biduleman) — Reporter (P1S) forcefully started a print needing ~260 g with only 180 g remaining on the first spool; the printer correctly consumed the first spool, AMS Backup auto-switched to a same-material second spool, and finished the print. Bambuddy then attributed ALL 260 g to the second spool; the first spool was left untouched in the inventory. Reads as a usage-attribution bug; root cause is two stacking firmware-quirk bugs that produce exactly the all-to-second-spool symptom for prints without per-layer 3MF gcode. Bug A — firmware reset of
total_layer_num.bambu_mqtt.py:2135wrotestate.total_layers = int(data["total_layer_num"])unconditionally on every push containing the field. P1S firmware (observed; matches the pattern other models resetlayer_num/progressvia at print end) pushes atotal_layer_num: 0frame at print completion. The unconditional write clobbered the slicer's actual total — by the time the usage-tracker ran a frame or two later,state.total_layerswas 0. The existing_last_valid_layer_numguard at line 2127 covered the same race forlayer_numbut the equivalent guard fortotal_layerswas never added. Bug B —usage_tracker.py:1129-1137dumped-all-to-last fallback. The mid-print tray-switch split path (which handles AMS Backup → second spool exactly like this scenario) has three attribution branches per segment: per-layer 3MF gcode (precise), linear by layer ratio (total_layer_num-based), and "remainder" (the last segment always getstotal_weight - sum_previous). When per-layer 3MF data is unavailable (force-started prints often lack it) ANDtotal_layers == 0(Bug A had just fired), the linear branch silently producedsegment_grams = 0.0for every non-last segment — so the entire print weight collapsed onto the last segment's remainder calculation. Path 2 (AMS remain% delta) couldn't recover because (1) the just-emptied spool typically reportsremain=-1after the empty event so the percent-delta calculation rejected it, and (2) the second spool's tray key had already been added tohandled_traysby Bug B's misallocation, suppressing the Path 2 lookup. End result: 260 g credited to spool 2, spool 1 left at 180 g unchanged — exactly the screenshot the reporter posted. Fix A —bambu_mqtt.py. Mirror the existing_last_valid_layer_numshape: only overwritestate.total_layerswhen the incomingtotal_layer_numis positive, so a firmware-reset frame can't clobber the cached value. The explicit reset to 0 on new print start now lives in the_handle_print_startblock at line ~3132 (right next to the existingstate.layer_num = 0) so the previous print's total still can't bleed into the next one before its first real push arrives. Fix B —usage_tracker.py. Cascade the linear-fallback denominator: trystate.total_layersfirst (the canonical source), thenlast_layer_num(the print's last-valid layer captured at completion time, already threaded into_track_from_3mfas a parameter for thelast_progresspartial-print case), then equal-split across segments as a last-resort fence — still wrong, but bounded. The original behaviour was strictly worse than equal-split: it always dumped 100% of the print's weight onto the last segment regardless of where the switch actually happened. Tests. 5 new backend cases.test_usage_tracker.py::TestTrayChangeSplit::test_tray_switch_uses_last_layer_num_when_total_layers_reset— the reporter's exact 260 g / 180 g split scenario withstate.total_layers=0andlast_layer_num=260, asserts 180.0 / 80.0 g attribution.test_usage_tracker.py::TestTrayChangeSplit::test_tray_switch_equal_split_when_no_layer_info_at_all— both denominators unavailable, asserts equal-split (30.0 / 30.0 g for a 60 g print) instead of the dump-to-last behaviour.test_bambu_mqtt.py::TestTotalLayersPreservation(× 3) — non-zero push sets the field; zero push preserves the cached value; new-print-start path explicitly resets to 0. Existing 7TestTrayChangeSplitcases (including the precise-per-layer-gcode happy path and thetotal_layers=100linear fallback regression at line 1045) still green — they use a positivetotal_layersso the new cascade is dormant for them. Scope check — what this does NOT change. The precise per-layer 3MF branch (extract_layer_filament_usage_from_3mfreturns data) is preferred over the linear fallback whenever per-layer data is available, so users who slice through PrintModal with full 3MF analysis stay on the precise path. Single-tray prints (no AMS Backup switch) never enter the split path at all (len(tray_changes) > 1gate). Path 2 (AMS remain% delta) is unaffected — it only fires for trays Path 1 didn't already attribute, and the fixed Path 1 covers the correct trays now. One intentional semantic shift worth flagging:state.total_layersnow persists across the firmware-end-of-print reset frame and between prints, instead of briefly dropping to 0 at completion and staying there until the next print's first push. The explicit reset in_handle_print_start(line ~3135, sibling to the existingstate.layer_num = 0reset) re-zeroes it cleanly on every new print, so the previous print's total still can't bleed into the next. Audited every consumer ofstate.total_layersacross the backend (main.py's first-layer notification,metrics.py's Prometheus gauge,spoolman_tracking.py's progress estimator,printers.py's REST response,mqtt_relay.py's relay payload,printer_manager.py's WS payload, the finish-photo-moment trigger atbambu_mqtt.py:2206): none distinguishes "no active print" bytotal_layers == 0— they all checkstate.statefor that. So the persistence change is invisible to every existing surface, and downstream consumers that DO read the value get a more reliable number at end-of-print and across a power-cycle. Full backendpytest -n 306222/6222 in 94 s; ruff clean. - "Prefer lowest remaining filament" did not actually pick the lowest spool, and could pick a near-empty spool on printers with AMS Filament Backup disabled (#1766, reported by biduleman) — Reporter (P1S) had
prefer_lowest_filament=truewith two identical-brand identical-color spools in the AMS, one with less remaining filament than the other; Bambuddy still picked the first slot every time. Root-cause investigation found TWO separate bugs feeding the one report. (1) The frontend sort never saw inventory grams. The backend has a two-tier sort (_prefer_lowest_sort_keyatprint_scheduler.py:1161) that puts inventory-bound spools in tier 0 (sorted bylabel_weight - weight_usedfor internal mode or Spoolman'sremaining_weightfor Spoolman mode) and MQTT-only spools in tier 1 (sorted by the printer'sremain%field) — but it only runs on queue items dispatched without a pre-setams_mapping. The PrintModal "Print Now" / "Add to Queue" flow pre-computes the mapping client-side and submits it, so the backend uses it as-is and the two-tier sort never fires for this path. The frontend's pre-compute (useFilamentMapping.ts::computeAmsMapping+useFilamentMapping,useMultiPrinterFilamentMapping.ts::computeMappingWithOverrides+computeMatchDetails,amsHelpers.ts::autoMatchFilament) only sorted byremain%and had no notion of inventory grams. For two RFID Bambu spools both reportingremain=100(freshly inserted, no recent prints) the sort tied at value 100, the slot-position tie-break favoured the lower slot, and the first slot always won — exactly what the reporter saw. (2) The sort had no notion of whether the printer could actually USE a near-empty spool. Without AMS Filament Backup enabled on the printer, the firmware will not switch to a second spool when the picked one runs out — so sorting toward the lowest left prints at risk of running dry mid-job. The user-side preference was completely ignorant of the printer-side capability that makes it safe. Fix — dispatch-time backend gate._compute_ams_mapping_for_printeratprint_scheduler.py:867coercesprefer_lowest=Falsewhenstatus.ams_filament_backup is False, logs[prefer-lowest] skipped (AMS Backup OFF on printer %s)so the decision is visible in support bundles without enabling DEBUG. Tri-state default:None(unknown / A1 family) applies the sort, preserving today's behaviour. Fix — banding-equivalent two-tier frontend sort. New exportedpreferLowestSortKey(f, inventoryByTrayId)inamsHelpers.tsmirrors the backend banding exactly — inventory-bound spools sort to tier 0, MQTT-only to tier 1, with backend-matching slot tie-break (amsId * 4 + trayIdfor regular AMS,1000 + (amsId - 128) * 4 + trayIdfor AMS-HT,10_000for external / VT so external always sorts LAST regardless of negative rawams_id). All seven frontend sort sites switched to it:autoMatchFilament+ 2 sites inuseFilamentMapping.ts::computeAmsMapping(top-level + nested for non-unique tray_info_idx) + 2 sites inuseFilamentMapping.ts::useFilamentMapping(the hook variant) + 2 sites inuseMultiPrinterFilamentMapping.ts(computeMappingWithOverrides,computeMatchDetails) +autoConfigurePrinter. Hook signatures grew an optionalinventoryByTrayId?: Map<number, number>param — undefined preserves pre-#1766 behaviour for any caller that hasn't wired it in. The banding tie-break alignment is load-bearing on its own: an earlier draft usedamsId * 4 + trayIdfor all slots, which givesams_id = -1(external) a NEGATIVE priority that would have beaten AMS slot 0 — caught during a second-round code audit and fixed before commit. Fix — frontend backup gate. New exportedeffectivePreferLowest(setting, amsFilamentBackup)mirrors the backend gate rule (!setting → false; backup === false → false; otherwise true). PrintModal computes it for the single-printer flow atindex.tsx:380;useMultiPrinterFilamentMappingcomputes it per-printer inside theprinterResults.map(different printers in the same dispatch can have different backup states, so a global flag would be wrong); PrinterSelector'sInlineMappingEditorandFilamentMapping.tsx's standalone editor (the per-AMS slot dropdown) both wired through. The standalone editor previously had NOpreferLowestawareness at all — its auto-suggestion could disagree with what would actually be dispatched. Closed in this change. Inventory map — single source of truth. NewGET /printers/{id}/inventory-remainendpoint (see Added entry above) exposes the same_build_inventory_remain_overridesresult the dispatcher uses, so PrintModal andFilamentMapping.tsxget the sameMap<global_tray_id, grams>the backend would compute. Internal AND Spoolman modes both work uniformly via the existing scheduler helper's branch — external / VT slots excluded, negative grams clamped. Frontend fetches per selected printer viauseQuerieskeyed on'printer-inventory-remain', 30 s staleTime, no fetch for unselected printers. An earlier attempt derived the map client-side from/inventory/assignmentsdirectly — that endpoint only reads the internal-modeSpoolAssignmenttable, so Spoolman users would have silently fallen back to remain%-only sorting. The dedicated endpoint closes that gap. Settings → Filaments tooltip. Explanatory note added under the "Prefer lowest remaining filament" toggle description: "Only takes effect when AMS Filament Backup is enabled on the printer — otherwise the printer cannot switch to a second spool when the picked one runs out." Save behaviour unchanged (existing debounced-save fires the "Settings saved" toast). i18n. New keysettings.preferLowestFilamentBackupNotetranslated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); parity check 5198 leaves per locale, no English fallback. Tests. 7 new backend cases —test_scheduler_backup_gate.py(tri-state gate × 4: backup OFF coerces, backup ON applies, None preserves today's behaviour, user setting OFF short-circuits regardless of backup) andtest_inventory_remain_endpoint.py(× 3: no status returns empty map, normal serialisation with string keys, no bindings returns empty). 8 new frontend cases —useFilamentMapping.test.ts(computeAmsMappinginventory × 3 +effectivePreferLowestgate × 5 + slot-priority banding regression × 1) andPrinterSelector.test.ts(autoMatchFilamentinventory × 3). Existing 56 + 27 cases still green — backwards-compat preserved by optional new params. Full backendpytest -n 306217/6217 in 74s; full frontendvitest2170/2170 in 29s; ruff clean;npm run buildclean; eslint clean; i18n parity green. - H2C nozzle pick from Bambu Studio not preserved on the dual-nozzle rack variant (O1C2) — plus a much wider silent-fallback bug across every Bambu Studio "Send" upload (#1780, reported by mkoreen) — The reporter's H2C consistently loaded R2 for HF prints and R4 for standard prints regardless of the nozzle they picked in Bambu Studio. First-attempt fix (commit d196cfc) was wrong about the root cause and didn't take. The real root cause, traced through mkoreen's
BAMBUDDY_VP_DUMP_WIRE=1capture + the 0.2.5b1 support bundle on 2026-06-21, is a filename-key mismatch in the VP intake cache that affected EVERY Bambu Studio "Send" upload across EVERY model, not just H2C. What was actually broken.mqtt_server.py:1296was passing the slicer's baresubtask_name(e.g.Filament_Track_Switch_Holder, no extension) into theon_print_command(filename, data)callback.on_print_commandthen stashed the slicer's print options under that bare name. But_add_to_print_queuelooked up the cache underfile_path.name— the FTP filename WITH extension (e.g.Filament_Track_Switch_Holder.gcode.3mf). The two strings never matched. The initialpopreturned None, the 2-second event wait fired against a key that the stash-side never signaled, the wait timed out, and every captured slicer field silently fell back to settings defaults. The user-visible blast radius: not just H2C'snozzle_mapping(which was added speculatively in d196cfc — see below), but ALSObed_leveling/flow_cali/vibration_cali/layer_inspect/timelapsefrom the original #1403 slicer-opts capture have been silently ignored since BambuStudio started putting the bare model name insubtask_nameand the extended filename in a separatefilefield. The mismatch survived unit tests because the existing fixtures calledon_print_command(file_path.name, {...})directly with the FTP filename — bypassing the brokenmqtt_server.pycaller. Fix.on_print_commandderivesstash_key = data.get("file") or filenameand uses THAT for both_slicer_print_options[...]and the event-signal lookup.filename(subtask_name) still flows to_schedule_finish_releaseunchanged — push_status echoes it back to the slicer asgcode_file/subtask_nameand the slicer matches against its own local subtask_name there, so changing what that path receives would have introduced its own regression (caught and reverted mid-audit). The new derivation falls back tofilenamewhendata["file"]is absent (legacy slicers / non-3MF uploads), so the change is strictly an improvement or a no-op — never a regression. H2C nozzle_mapping rides through correctly now. Thenozzle_mappingarray BambuStudio writes intoproject_file.printat the top level (verified via wire capture: 32-entrylist[int]matching the printer'sget_auto_nozzle_mappingreply) now reaches the queue item and is replayed on the dispatchedproject_filecommand viabambu_mqtt.py::start_print(nozzle_mapping=…), gated by theis_dual_nozzleflag at runtime. Nullable TEXT columnnozzle_mappingonprint_queue— non-branched ALTER matching theams_mapping/filament_overridesprecedent. The dispatcher parses the JSON-string column back to alist[int]on the wire so the shape matches BS exactly. Fail-open on malformed JSON: unparseable column logs a WARNING and omits the field — firmware then runs its auto-pick, never worse than pre-fix.nozzles_infois dead code, dropped. The original #1780 fix also captured anozzles_infofield that was a best-guess based on the OrcaSlicer source. The wire capture confirmed BambuStudio never actually sends it — the field doesn't exist on the project_file body for H2C. Captured-but-unused code removed frommanager.pyintake,bambu_mqtt.pydispatch + signature,printer_manager.pykwarg,print_scheduler.pycall,PrintQueueItemUpdate+PrintQueueItemResponseschemas, and theprint_queue.pyroute's parse / serialise paths. The DB column is kept nullable so old rows still load — nothing reads or writes to it anymore. Diagnostic log added._add_to_print_queuenow logs at DEBUG when slicer_opts is None at the end of intake, including the looked-up key and the actual cache keys present. A future stash/lookup mismatch will surface in 30 seconds of log reading instead of needing a wire capture to diagnose. Behaviour change worth flagging. Users with Bambu Studio-set values for bed-leveling / flow-cali / vibration-cali / layer-inspect / timelapse that differ from the Bambuddy default-workflow settings will see those slicer choices honored now instead of silently overridden. This restores the original #1403 intent — slicer values take precedence, settings defaults are the fallback. Tests. 3 new regression cases intest_virtual_printer.py::TestVPProjectFileStashKeypin the contract: the FTP filename (fromdata["file"]) MUST be the stash key, the bare subtask_name fallback whenfileis absent, and the wait-event signal must fire under the FTP-filename key even when the callback was called with the bare subtask_name. All 3 would fail under the pre-fix code. Existing nozzle / dispatch tests (TestStartPrintNozzleMappingDispatch× 5,test_virtual_printer.py::TestVirtualPrinterInstance× 3) updated to drop the now-removednozzles_infoassertions.test_printer_manager.py::test_start_print_calls_clientupdated for the dropped kwarg. Full backendpytest -n 306274/6274 in 92.65s; ruff clean;npm run buildclean; vitest 2156/2156 + QueuePage 26/26 + FileManagerPage 51/51 green; i18n parity green. Scope. The stash-key fix affects every queue-mode Virtual Printer regardless of model. The H2C-specific nozzle_mapping pass-through is the user-visible surface that motivated finding the bug. H2D / X2D dual-extruder routing was never affected — those useams_mapping2(ams_id 254/255), already forwarded correctly. No DB migration changes (thenozzles_infocolumn stays on disk, just unused), no permission change, no i18n keys, no frontend changes. - Docker installer fails on the default
/opt/bambuddypath with "Permission denied" (#1774, reported by jmoore-skild) —install/docker-install.sh::create_install_dir(line 252) ranmkdir -p "$INSTALL_PATH"without sudo whileDEFAULT_INSTALL_PATH="/opt/bambuddy"(line 32) — root-owned on every Linux distro.set -eat line 20 then aborted the whole run before docker compose could ever pull the image. Anyone following the documentedcurl … | bashflow as a normal user hit this immediately. The native installer atinstall/install.sh:361already handles the same situation correctly withsudo mkdir -p+sudo chown; the Docker variant just never got the same treatment. Why the fix isn't a default-path change: the contributor's first instinct was to drop the default to~/bambuddysince the Docker installer only writesdocker-compose.yml+.envon the host (real app data lives in named volumes), butinstall/update.sh:4andinstall/update_macos.sh:4both defaultINSTALL_DIRto/opt/bambuddy, andinstall/README.md:274documentsINSTALL_DIR=/opt/bambuddy sudo ./update.shfor the update flow — changing the install default without coordinating the update path would silently break self-service updates for anyone following the docs verbatim. The actual gap is the missing privilege escalation increate_install_dir, not the default path. Fix:create_install_dirnow triesmkdir -p "$INSTALL_PATH" 2>/dev/nullfirst — the cheap no-sudo path covers--path ~/bambuddy,--path /srv/bambuddy, and any other writable target — and only falls back tosudo mkdir -p "$INSTALL_PATH"+sudo chown -R "$USER:$USER" "$INSTALL_PATH"when the unprivileged attempt fails. The chown is load-bearing: without it, the script would later try to writedocker-compose.ymland.envinto a root-owned dir as the unprivileged invoking user, kicking off a cascade of EACCES failures further down. Idempotent on re-run (the secondmkdir -psucceeds against the now-owned dir, no second sudo prompt).set -esurvives the redirected stderr because theif !construct is the documented escape from bash's exit-on-error semantics for an expected-failure check. Smoke-tested all three branches: writable target → no sudo prompt fires; idempotent re-run → no second sudo prompt; the failing-mkdir-then-fallback path →set -esurvives intact. What this does NOT change: the default install path stays/opt/bambuddyfor parity withinstall.sh/update.sh/ the documented update flow; the Windows mirror atinstall/docker-install.ps1already uses$env:USERPROFILE\bambuddy(per-user convention on Windows) and is untouched. No docs change required —install/README.mdand the wiki Docker page (bambuddy-wiki/docs/getting-started/docker.md) both still accurately describe the behaviour. - MakerWorld import/resolve/status fail under API-key auth even when the owner has a Bambu Cloud login (#1777, reported by Mx772) — The reporter (working on a browser extension that drives Bambuddy via
X-API-Key) noticed thatPOST /api/v1/makerworld/importandPOST /api/v1/makerworld/resolvereturned{"detail":"Downloading files from MakerWorld requires a Bambu Cloud login"}even when the key's owning user had a valid stored Bambu Cloud session, and the same imports succeeded from the web UI. Root cause is exactly the shape the reporter traced:require_permission_if_auth_enabledinbackend/app/core/auth.py:1414deliberately returnscurrent_user=Nonefor API-keyed callers — the comment at line 1408 makes this explicit and points atcloud.pyfor the resolver. The MakerWorld routes never got that resolver wired in, so_build_service(db, None)→get_stored_token(db, None)→ no token → the "requires Bambu Cloud login" branch fires regardless of what the owning account has set up. Same shape #1182 fixed for cloud slicer presets, and the canonical fix for non-/cloud/*routes is already in the codebase asresolve_api_key_cloud_owner(cloud.py:128-160) — used byslicer_presets.py:491andlibrary.py:3871. The MakerWorld routes were missing the wire-up. Fix: Three routes get the extraapi_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner)parameter —get_status,resolve_url,import_instance— and each resolvescloud_token_user = current_user or api_key_cloud_ownerbefore callingget_stored_token/_build_service.import_instanceadditionally usescloud_token_user.idfor theowner_idargument tosave_3mf_bytes_to_library(which translates toLibraryFile.created_by_id), so library rows imported via API key are now attributed to the key's owner instead of staying NULL./recent-importsis unchanged — it only usescurrent_useras a permission gate (_ = current_user) and never touches the cloud token. The fix preserves fail-closed semantics for keys without thecan_access_cloudflag:resolve_api_key_cloud_owneralready fences onapi_key.user_id is not None and api_key.can_access_cloud(cloud.py:158), so a key with only the per-route scope (can_read_status/can_manage_library) still surfaces the "requires Bambu Cloud login" error path — no new auth gap. Two scope fields the API key needs: the per-route scope (MAKERWORLD_VIEW→can_read_status,MAKERWORLD_IMPORT→can_manage_libraryper_APIKEY_SCOPE_BY_PERMISSIONincore/auth.py) AND the orthogonalcan_access_cloudflag (separate column on theapi_keystable). The fix doesn't change that surface — it just stops dropping validcan_access_cloud=Truekeys on the floor. Tests: 6 new cases inbackend/tests/integration/test_makerworld_apikey_auth.pypinning the full surface — API key withcan_access_cloud=True+ owner-has-token →/statusreportshas_cloud_token=True,/resolvebuilds the service with the owner User (asserted on the_build_servicemock's call args),/importsucceeds end-to-end and the resultingLibraryFile.created_by_idmatches the API-key owner; API key withcan_access_cloud=False→ status still reportshas_cloud_token=False(no widening) and import-row'screated_by_idstays NULL; JWT-authenticated parity check confirms the existing user-session flow is unchanged by the addedDepends. 6/6 new tests green; full backend suite (6157 tests) still green; ruff clean. No frontend change, no DB migration, no new permission, no new dependency. The reporter's browser extension and any other API-keyed Home Assistant / automation integration unblocks immediately on next deploy. - Archive thumbnails missing for prints sliced via the docker sidecar (#1759, reported by VID-PRO) — The reporter (P2S) noticed every print sliced through Bambuddy's BS docker sidecar landed in the archive with no thumbnail, while the same model sliced from desktop Bambu Studio on their laptop showed the cover image. The "Some recent prints couldn't be archived with thumbnails" banner pointed at install step 4 (
Store sent files on external storage) which is unrelated — that flag is set on FTP-fetch failures, not on missing-thumb in the sliced 3MF. Root cause is upstream of Bambuddy entirely: neither the BambuStudio CLI nor the OrcaSlicer CLI rendersMetadata/plate_N.pngwhen invoked headlessly with--slice --export-3mf. That render is a separate code path triggered by the--export-pngflag, which is mutually exclusive with--export-3mfand additionally requires a working display backend (BS 02.07.x's bundled GLFW is hard-locked to Wayland — evenXDG_SESSION_TYPE=x11+GDK_BACKEND=x11+QT_QPA_PLATFORM=xcbdon't switch it back to X11, so an Xvfb display in the sidecar wouldn't help even if we wired a second-pass call). Confirmed empirically by feeding a thumbnail-strippedCube-MegaS.3mfthrough both sidecars: both produced.gcode.3mfwith zero PNG entries. The Orca sidecar has been silently shipping thumbnail-less 3MFs from STL inputs since it launched; nobody noticed until VID-PRO filed this against BS specifically. Fix: Newbackend/app/services/plate_thumbnail.pyrenders the missing thumbnails server-side after the slice returns.inject_plate_thumbnails_if_missing(threemf_bytes)parses the sliced zip, finds everyMetadata/plate_N.gcodeentry that doesn't have a matchingplate_N.png, loads3D/3dmodel.modelvia trimesh, renders an isometric Bambu-green-on-dark view at 512×512 (plate_N.png) + 128×128 (plate_N_small.png) using the same matplotlib Agg pipeline asstl_thumbnail.py, and re-packs the zip with the PNGs injected. Visual style deliberately matches Bambuddy's existing library thumbnails — archive cards stay consistent inside Bambuddy rather than chasing parity with desktop Studio's plate render. Best-effort: input bytes are returned unchanged on any failure (no model file, trimesh can't parse, matplotlib render fails) so the slice flow itself can't fail because of a missing thumbnail. Idempotent: re-running on a previously-injected 3MF hits the no-op fast path and returns the input verbatim. Wired into bothbackend/app/api/routes/library.pyslice paths (library-file slice at line 3593 + archive re-slice at line 3718) viaresult = result._replace(content=inject_plate_thumbnails_if_missing(result.content))immediately beforeout_path.write_bytes(...)— covers the cross-class merged-multi-plate path (slicer_3mf_convert.merge_plate_3mfs) automatically since merged bytes flow into the same write site. Dependencies: trimesh's 3MF loader importsnetworkx(scene-graph traversal) andlxml(model.xml parse) lazily inside the 3MF code path — both added torequirements.txtbecause they aren't strict trimesh transitives but the loader fails at runtime without them (ModuleNotFoundError). Tests: 7 new cases inbackend/tests/unit/services/test_plate_thumbnail.py: input bytes returned unchanged (identity) when every plate already has a thumbnail (desktop-Studio fast path); both PNG sizes injected when missing; injected PNGs decode as 512x512 + 128x128 RGBA; multi-plate 3MF with one pre-existing thumbnail only renders the missing slots (pre-existing bytes preserved verbatim); 3MF with no3D/3dmodel.modelreturns input unchanged; non-zip input returns input unchanged; idempotent on second pass. Verified end-to-end: runninginject_plate_thumbnails_if_missingagainst the actual BS sidecar and Orca sidecar outputs (/tmp/bs-no-thumb-out.3mf//tmp/orca-no-thumb-out.3mf— both 25932/25992 bytes with zero PNG entries) produces 3MFs with validMetadata/plate_1.png+Metadata/plate_1_small.pngcontaining the rendered cube model (38.5% Bambu-green pixel coverage confirms the model is actually drawn, not a blank canvas). 6151/6151 backend tests still green; ruff clean. No sidecar Dockerfile change required — earlier experiments with Xvfb +xvfb-runinDockerfile.bambu-studiowere a false start (the BS GLFW Wayland lock means no X display can help) and have been reverted from the sidecar repo. No frontend change required — the archive UI already extractsplate_1.pngfrom the sliced 3MF, the cards just had nothing to show. - Local Presets page: deleted row stayed visible until refetch returned, allowing a second delete click → 404 — On the Slicer → Local Profiles page, clicking Delete → Confirm fired the
DELETE /api/v1/local-presets/{id}request, then theonSuccesshandler closed the confirmation modal and calledqueryClient.invalidateQueries({ queryKey: ['localPresets'] })without awaiting it. The global QueryClient defaultstaleTime: 1000 * 60(App.tsx:78) doesn't blockinvalidateQueriesfrom refetching, but the refetch is async — so for ~hundreds of ms the rendered table still showed the just-deleted row, and a quick re-click on the same row opened a fresh confirm dialog → second confirm → backend returns 404 (row already gone) → confusing error toast. Caught while reproducing #1713: log showedDELETE /api/v1/local-presets/42 → 200followed by two→ 404for the same id within 4 seconds. Fix: Add an optimisticqueryClient.setQueryData<LocalPreset[]>(['localPresets'], …)infrontend/src/components/LocalProfilesView.tsx::deleteMutation.onSuccessthat filters the deleted row out of the cached list synchronously, then leaves the existinginvalidateQueriescalls in place to reconcile any drift. Row disappears the instant the DELETE returns 200, no re-click window. The same import path'simportMutationdoesn't need the same treatment because additions can't trigger the symmetric "row I just acted on is still there" → 404 loop. ESLint clean;npm run buildclean; existingLocalProfilesView.test.tsxsuite still green (no new test added — the bug is a render-timing window the existing render-based vitests don't observe; the existing onSuccess assertions still pass with the new optimistic write). - SpoolBuddy inventory search now matches spool ID, slicer filament name, and storage location (#1738, reported by shaddowlink) — The reporter found that typing a numeric spool ID into SpoolBuddy → Inventory's search box returned no results, even though the same query in Bambuddy's main Inventory page worked. Root cause:
frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx:147-155reimplemented the search filter inline and only matchedmaterial,subtype,brand,color_name, andnote. The main Inventory page delegates to the sharedfilterSpoolsByQueryhelper infrontend/src/utils/inventorySearch.ts:7, which additionally matchesString(spool.id),slicer_filament_name, andstorage_location. SpoolBuddy had diverged. Fix: replace the inline filter with a single call tofilterSpoolsByQuery(list, searchQuery.trim()). Both inventory modes (internal viagetSpools, Spoolman viagetSpoolmanInventorySpools) return the sameInventorySpoolshape, so this covers both paths in one drop. SpoolBuddy now matches Bambuddy's search behaviour across all eight fields. Tests: newSpoolBuddyInventorySearch.test.tswith 4 cases pinning the parity — exact spool ID match, partial spool ID match, the five pre-fix fields still match, and the three newly-included fields (storage_location, slicer_filament_name, plus implicit id) match. ExistinginventorySearch.test.tsID matching test (#1336) still green. ESLint clean;npm run buildclean. No backend change, no i18n, no new permission. - Sidebar entries for Files / Archives / Queue no longer hide from non-admin users with granular read access (#1755, reported by knifesk) — The reporter noticed the File Manager sidebar entry was hidden for a default Operators user even though the same user could load
/filesdirectly and the backend API accepted their requests. Root cause is broader than reported:frontend/src/components/Layout.tsx::navPermissionsmappedfiles → 'library:read',archives → 'archives:read',queue → 'queue:read'— the LEGACY permission flags — but the default Operators group atbackend/app/core/permissions.py:368-380is seeded with the GRANULAR variants only (ARCHIVES_READ_OWN.value,QUEUE_READ_OWN.value,LIBRARY_READ_OWN.value). The migration path atbackend/app/core/database.py:3034-3041also flips legacy*:read→*:read_ownon existing non-admin groups. So a non-admin user never holds the legacy permission,hasPermission('library:read')returns false, sidebar entry is suppressed — for all three resources, not just Files. Admins getALL_PERMISSIONSwhich includes the legacy variant, so the sidebar always renders for them, which is why this regression went unnoticed until a real non-admin Operator account landed in #1755. Fix:navPermissionsnow acceptsPermission | Permission[]and the three affected resources list all three tiers (*:read,*:read_own,*:read_all). TheisHiddencheck switches on the array type —some(hasPermission)for arrays, current behavior for single values. Nothing else in the gate logic changed.frontend/src/api/client.tsPermission type extended with the missing granular variants (archives:read_own,archives:read_all,queue:read_own,queue:read_all,library:read_own,library:read_all) — these existed in the backend enum and were already being shipped to the frontend in/auth/me, but the TS type didn't declare them so any new code wanting to gate on the granular tier would TypeScript-error. What this also fixes downstream: any future feature that needs to gate UI on*:read_own/*:read_allcan now do so without re-adding the same type entries. Tests: 5 new cases inLayout.test.tsx::'Sidebar gate accepts granular read tiers (#1755)'— Files visible with onlylibrary:read_own, Files visible with onlylibrary:read_all, Archives visible with onlyarchives:read_own, Queue visible with onlyqueue:read_own, and the negative case (printers:readonly — none of Files / Archives / Queue render). 22/22 Layout vitests green; ESLint clean;npm run buildclean. No backend change, no DB migration, no new i18n keys. No new permission — just unmasks UI for users who already had backend access. - Push notification for "Printer offline" now actually fires (#1752, reported by saint-hh) — The notification provider's
on_printer_offlinetoggle has shipped since the notifications feature landed: schema field, DB column,notification_template.pyentry, and the dispatcherNotificationService.on_printer_offline(printer_id, printer_name, db)are all in place. What was missing was the caller — nothing in the codebase actually invoked the dispatcher when a printer went offline. The reporter (P2S, smart-plug-cuts-power scenario) confirmed turning the toggle on did nothing; only the print-failure notification fired when power was restored, via the firmware'sgcode_state=FAILEDreport on MQTT reconnect. Why the toggle was orphan: every other provider event (on_print_start,on_print_complete,on_print_progress,on_printer_error, etc.) has a clear call site undermain.py::on_printer_status_changeor alongside the print-lifecycle hooks. The offline event was the only edge-triggered toggle without one — the dispatcher and template predated the wiring step and were silently shipped. Both upstream offline-trigger paths (smart_plug_manager→printer_manager.mark_printer_offline()andbambu_mqtt.py::check_stalenessafter the 30s STALE_RECONNECT_COOLDOWN) route through_on_status_changealready and reachon_printer_status_change; the handler just didn't act on the disconnect edge. Fix: edge detection inon_printer_status_changewatchesstate.connectedagainst the previous observation per printer (_printer_last_connected: dict[int, bool]). On the True → False transition it schedules_maybe_notify_printer_offline(printer_id)as a background asyncio task; on the next True observation it cancels any pending task. The helper sleeps_PRINTER_OFFLINE_NOTIFY_DEBOUNCE_SECONDS = 60.0then re-checksprinter_manager.is_connected(printer_id)— only fires the notification if the printer is still offline. Why 60s debounce: sized againstbambu_mqtt.py::STALE_RECONNECT_COOLDOWN = 30s— a single stale-trigger + reconnect cycle isn't enough to fire, only a real outage that survives one full cooldown notifies. Transient MQTT blips (WiFi roam, broker reload, brief packet loss) recover within the window and the cancellation path kicks in. Edge-case handling: initial observation with no prior connected state doesn't fire (covers Bambuddy startup with an already-offline printer); a False → False repeat doesn't reschedule (the in-flight task stays in place rather than resetting the clock on every status callback, which would otherwise mean the notification never fires); the task entry pops from_printer_offline_notify_tasksin the finally block whether the notification fired, the printer reconnected, or the task was cancelled mid-await. No symmetricon_printer_onlineevent: the reporter explicitly noted the "printer lost power and interrupted the print" notification already fires when power is restored — that's the print-failure notification, triggered by the firmware reportinggcode_state=FAILEDfor the interrupted print on MQTT reconnect. That covers the "printer is back" channel without a new toggle. If the user then resumes the print, no print_start notification fires (Bambuddy'sbambu_mqtt.py:3039explicitly suppressesis_new_printfor PAUSE → RUNNING to prevent duplicates when resuming from pause), but that's a separate scope from offline-detection. Tests: 9 new cases intest_printer_offline_notification.pysplit across two classes.TestMaybeNotifyPrinterOfflinepins the debounced helper: fires notification when still offline at end of window, doesn't fire when printer reconnected during debounce, doesn't fire when the printer disappeared from the DB (uninstall mid-window), clears_printer_offline_notify_tasks[printer_id]after run.TestOfflineEdgeDetectionpins the edge logic insideon_printer_status_change: first observation (connected) doesn't schedule, first observation (disconnected) doesn't schedule (the no-prior-True case — important for startup), True → False schedules a task, reconnect cancels the pending task, repeated False observations don't replace the in-flight task. Full backend suite still green; ruff clean. - Chamber-fan badge shown on open-frame Bambu printers that have no chamber fan (reported off-list, screenshot of an A1) — The Printers page rendered three fan widgets (part cooling / auxiliary / chamber) for every printer unconditionally at
PrintersPage.tsx::fanItems(around line 3579), readingcooling_fan_speed/big_fan1_speed/big_fan2_speedoff the status payload. Open-frame Bambu models (A1, A1 Mini, A2L, P1P) physically have no chamber fan — the firmware reportsbig_fan2_speedas 0 there, so the badge always rendered greyed-out and clicking it would let the user "set" a speed on a fan that doesn't exist. Closed-frame models (X1C / X1 / X1E / X2D / P1S / P2S / H2D / H2D Pro / H2C / H2S) are unaffected. Fix: new module-scopeMODELS_WITH_CHAMBER_FAN: ReadonlySet<string>allowlist nearmapModelCode; the chamber entry is now spread intofanItemsonly whenMODELS_WITH_CHAMBER_FAN.has(printer.model ?? ''). Open-frame printers drop to a 2-badge row (part + aux), which is what their hardware actually has. Why an allowlist (not a denylist): the existing enclosure-door-badge gate atPrintersPage.tsx:3314uses an explicit model list pattern; mirroring it keeps the file's classification convention consistent and means a new Bambu model added to the codebase has to be deliberately added to the chamber-fan set rather than silently inheriting (failure mode: missing widget on a real chambered printer, noticed immediately and trivially fixed) — better than the denylist's failure mode (phantom widget on a new open-frame model, looks correct, silently lies). The set deliberately excludes P1P: open-frame, no chamber fan, even though the door-badge list includes it (separate pre-existing inconsistency, not in scope to fix here). Tests: 5 new cases inPrintersPage.test.tsx::'fan badges'— hides on A1 Mini, hides on A1, hides on P1P, shows on X1C, shows on P1S. Each constructs a single-printer mock with the model under test plus a status payload that has all three fan speeds populated (53 / 53 / 53), then asserts thetitle='Chamber Fan'element is/isn't in the DOM and that the part-cooling / auxiliary badges still render unchanged so we're not over-filtering. Existing 51 PrintersPage cases still green;npm run buildclean; ESLint clean. Scope clarification — what this does NOT change. The/api/v1/printers/{id}/fanbackend route still acceptsfan=chamberrequests for all models (the dispatch surface stays uniform, so external automations / MQTT relays don't need a model-aware gate); the chamber-fan icon's behaviour and theprinters.fans.chamberi18n string are unchanged on chambered models; the chamber-temperature widget is not affected (open-frame models' status payload doesn't include a chamber temp field, so it was already absent there). - In-app "Install Update" on Windows installer installs failed with "Could not find git executable" — Reported off-list by a Windows user running the
.exeinstaller. Root cause has two layers, and the surfaced error only described the first. Layer 1 — git not findable on Windows.backend/app/api/routes/updates.py::_find_executable("git")falls throughshutil.which("git")(the installer doesn't bundle git, fresh Windows installs have no git on PATH) into a fallback list of Unix-only paths (/usr/bin/git,/opt/homebrew/bin/git,~/.local/bin/gitetc) — none exist on Windows, so the helper returnsNoneand the route reports "Could not find git executable. Please ensure git is installed." Layer 2 — no.gitdirectory in the installer payload anyway.installers/windows/build.py::stage_backendcopiesbackend/viashutil.copytreerather
Changelog truncated — see the full CHANGELOG.md for the complete list.