# Changelog All notable changes to Bambuddy will be documented in this file. ## [0.2.4.7] - 2026-06-14 ### 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 transitive `esbuild` floor to 0.28.1, which closes the last open advisory in the audit chain. **Bambuddy-side surface audited:** `vite.config.ts` uses only stable contracts that survived the v8 cut — `defineConfig`, the `Connect` type, the custom `serveGcodeViewer` `configureServer` middleware plugin (proxies `/gcode-viewer/*` to the repo's sibling `gcode_viewer/` directory in dev), the `server.proxy` with WebSocket upgrade for `/api/v1/ws`, `build.outDir`/`emptyOutDir`/`chunkSizeWarningLimit`, and `resolve.alias` for `@`. `base: '/'` regression guard from #1221 is unaffected. No SSR, no library mode, no CSS preprocessors, no exotic plugins. `vitest@4.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:** `dompurify` 3.4.0 → 3.4.10. `package.json` floor raised from `^3.4.0` to `^3.4.10` so 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 with `selectedcontent` + `command` + `commandfor` (all valid modern HTML, harmless for our two default-allow-list call sites), and `ProjectPageModal` is unaffected anyway because it sets an explicit `ALLOWED_TAGS` / `ALLOWED_ATTR` whitelist. **Build / lint / test tooling (transitive, dev-only):** `@babel/core` 7.29.0 → 7.29.7 (pulled by `@vitejs/plugin-react` and `eslint-plugin-react-hooks`), `vite` 7.3.2 → 7.3.5, `markdown-it` 14.1.1 → 14.2.0 (pulled by `@tiptap/extension-link` → `@tiptap/pm` → `prosemirror-markdown`; Bambuddy never calls `markdown-it.render` directly so the change is transparent), `js-yaml` 4.1.1 → 4.2.0 (pulled by `eslint`), `form-data` 4.0.5 → 4.0.6 + `ws` 8.20.1 → 8.21.0 (both pulled by `jsdom` in the test runtime). All bumps inside existing semver ranges except `dompurify`. No source changes required. - **`dompurify` 3.4.10 → 3.4.11** — Follow-up patch closes a moderate-severity advisory affecting `setConfig()` callers: the previous hook clone-guard added in 3.4.7 could be bypassed via `setConfig()`, leaving a permanent `ALLOWED_ATTR` pollution that the next `sanitize()` call inherited. **Bambuddy's exposure is nil** — `git grep DOMPurify.setConfig` returns 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`) call `DOMPurify.sanitize(html)` or `DOMPurify.sanitize(html, {ALLOWED_TAGS, ALLOWED_ATTR})` directly, never through `setConfig()`. The bump is taken as defence-in-depth to keep XSS-sensitive surface area current and to silence `npm audit` so future audit-fix runs don't auto-bundle unintended changes. **Mechanical lockfile bump only:** the existing `^3.4.10` range already permitted 3.4.11, so `package.json` is unchanged; `package-lock.json` updates the resolved URL + integrity hash for the one entry. Verification: `npm audit` reports 0 vulnerabilities, `MakerworldPage.test.tsx`'s 12 DOMPurify sanitisation cases pass, `npm run build` clean. - **Backend dependency security floor raises (cryptography / python-multipart / starlette)** — pip-audit December 2026 cycle surfaced six advisories across three direct deps; floors in `requirements.txt` lifted to the documented fix releases, plus one transitive co-bump for resolver compatibility. **`cryptography` 46.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 to `cryptography_decrepit`); v48.0.0 dropped `PUBLIC_KEY_TYPES` / `PRIVATE_KEY_TYPES` type aliases. Bambuddy's grep is clean across every one of those: `core/encryption.py` uses Fernet (AES-128-CBC + HMAC), `services/spoolbuddy_ssh.py` uses ed25519, `services/virtual_printer/certificate.py` uses RSA + x509 + ExtendedKeyUsageOID. Python 3.13 + OpenSSL 3.x on container, so the version-floor bumps are no-ops for us. **`python-multipart` 0.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 extended `filename*` / `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 both `filename=` and `filename*=` keep working via the plain `filename=` 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 `&`. **`starlette` 1.1.0 → 1.3.1 floor** — clears CVE-2026-54282/54283 (FormParser `max_part_size` / `max_fields` limits now actually enforced after being declared-but-ignored in earlier releases; `StaticFiles.lookup_path` rejects absolute paths; `FileResponse` clamps oversized suffix range requests; `URL.replace()` IndexError fix). **Critical pre-bump check:** the newly-enforced `max_part_size=1MB` default would have broken every file upload (`UploadFile = File(...)` in `inventory.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 the `MultiPartParser.on_part_data` source: the size check at `if 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/1428` replaces 3 references of `status.HTTP_422_UNPROCESSABLE_ENTITY` (deprecated in starlette 1.3.x) with `HTTP_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). **`pyopenssl` 26.0.0 → 26.3.0 floor** — **NOT a security fix**; required because pyOpenSSL `<26.3.0` caps `cryptography<47` in 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 direct `from OpenSSL ...` imports — pyOpenSSL is pulled transitively by `asyncssh` + `pywebpush`. **Verification:** `pip-audit` clean, `pip check` clean, `ruff check backend/` clean, backend `pytest -n 30` 6167/6167 in 86.55s. No DB migration, no API surface change, no permission change, no frontend change. ### Added - **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-settings` so Matomo can split-track this surface against the website's own positions. Motivation lives in `bambuddy-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.html` reaches only 1.18% of website visitors over the May 21 - Jun 19 window even though `/installation.html` reaches 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 new `sponsors.*` 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 the `bambuddy-website` repo's `installation.html`, plus a `?from=install-bottom` tracking 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.html` despite 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's `created_at` (auth-enabled), or `MIN(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 single `last_shown_at` column 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 a `user_id NULLABLE` column: 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 SQL `WHERE` level (`column IS NULL` vs `column = X`), no doubled storage logic, no duplicated trigger code. Counter queries for prints / cost / archives use `print_log.created_by_id IS NULL` for the install-default count. **Backend.** New `SponsorToastState` model (`backend/app/models/sponsor_toast_state.py`) with columns `user_id` (nullable FK with `ON DELETE CASCADE` so a deleted user takes their toast state with them), `last_shown_at`, `milestones_seen` (Text storing a JSON-serialised `list[str]` of fired milestone keys for SQLite/Postgres uniformity), `last_seen_version`, plus standard `created_at`/`updated_at` timestamps. UNIQUE constraint on `user_id` so there can be at most one row per user (or exactly one NULL-keyed row). The table is created via `Base.metadata.create_all()` at init — no explicit migration in `run_migrations()` needed since this is a brand-new table, not an ALTER on an existing one. **Service.** `backend/app/services/sponsor_prompt.py` with two public entry points: `evaluate(db, user_id_or_None) -> Trigger | None` walks 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 to `milestones_seen` (one-shot families) or just bumps `last_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 sums `print_log.cost + print_log.energy_cost` so the threshold reflects total spend Bambuddy has tracked, not just material. **Routes.** `GET /api/v1/sponsor-prompt/check` returns `{show: false}` or `{show: true, milestone, family, threshold, payload}`; `POST /api/v1/sponsor-prompt/dismiss` takes `{milestone: string}` and returns 204. Both gated with `Permission.SETTINGS_READ` via `RequirePermissionIfAuthEnabled` — every authenticated user has this, and auth-disabled installs hit them with `current_user = None` and the service handles that as the install-default row. **Frontend hook.** New `useSponsorPrompt(currencyCode)` hook (`frontend/src/hooks/useSponsorPrompt.ts`) fires once per browser session after auth resolves: checks `sessionStorage['sponsorPromptShown']` to avoid double-firing on a single session's mount/unmount cycles (Layout re-renders, navigation, etc.), then calls `sponsorPromptApi.check()`. If a trigger comes back, builds the localised message via the new `sponsors.toast*` keys and displays a persistent toast with a "View supporters" CTA linking to `https://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 fires `sponsorPromptApi.dismiss(milestone)` to anchor the cooldown server-side and closes the toast. The hook is wired into `Layout.tsx` (which sits inside `` so auth has already resolved) and pulls `settings.currency` from the existing settings useQuery — no duplicate fetch. **Toast extension.** Existing `ToastContext` extended with optional `action: { label, href, onClick }` on `showPersistentToast`. The non-dispatch toast renderer gets a new branch: if `action` is present, render an inline `` styled as a small bambu-green pill before the dismiss-X. Click on the action fires its `onClick` (used by the sponsor hook to call dismiss) and closes the toast. Existing showToast / showPersistentToast call sites are unaffected — `action` is optional, omitting it gives the previous icon + message + X behaviour exactly. **i18n.** 5 templated keys in the existing `sponsors.*` 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 existing `getCurrencySymbol` util returns the right glyph from `settings.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 ``. **Tests.** 24 new cases. 20 in `backend/tests/unit/test_sponsor_prompt_service.py` covering: 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 in `backend/tests/integration/test_sponsor_prompt_api.py` covering: `/check` returns `{show: false}` on empty install, `/dismiss` 422 on missing milestone, `/dismiss` 204 on success, check-then-dismiss-then-recheck-is-silent (cooldown anchors even when the original check returned `show: false`). Frontend: existing `ToastContext.test.tsx`, `Layout.test.tsx`, `SettingsPage.test.tsx` all 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 backend `pytest -n 30` 6250/6250 in 64 s; ruff clean (4 import-order auto-fixes applied); ESLint clean; `npm run build` clean (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=&key=`. `v=1` first so future bumps to `v=2` have a clean deprecation path; both values URL-encoded so reserved characters in either don't corrupt the parse. The builder lives in `frontend/src/utils/apiKeyQr.ts` exporting `buildApiKeyQrPayload()` + `API_KEY_QR_VERSION` so any future mobile-side parser has a stable shared constant to anchor against. **`baseUrl` source:** prefers the configured **External URL** setting (Settings → Network), falling back to `window.location.origin` if not set, so the encoded address is reachable from a phone behind a reverse proxy / Docker host. The fallback's failure mode (admin on `http://localhost:8000` without 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-memory `createdAPIKey`** React state — the key is never persisted, never re-fetched (keys are stored hashed at `/api/keys` POST and returned in plaintext exactly once), and never round-trips to the server. No download button (intentional contrast with the existing `QRCodeModal.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 both `showApiKeyQR` and `createdAPIKey` so 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:** new `frontend/src/components/ApiKeyQRCodeModal.tsx` using `qrcode.react`'s `QRCodeSVG` at 256 px (renders Version 5 / 6 territory for the typical ~120-character payload, comfortably below the alphanumeric capacity). **Dependency:** `qrcode.react ^4.2.0` added to `frontend/package.json` (+21 KB raw / ~9 KB gzip to the bundle). Existing `frontend/src/components/QRCodeModal.tsx` is untouched — different purpose (server-rendered PNG for archive deeplinks), different component, no collision. **Tests:** `frontend/src/__tests__/utils/apiKeyQr.test.ts` pins the contract — scheme + `v=` first, exact encoding of `https://printer.local` + `bb_abc123` byte-for-byte, special-character round-trip (`+`, `/`, `=`, `&`, spaces), explicit assertion that the raw unencoded key never leaks into the payload, and a `URLSearchParams` round-trip that re-parses `v` / `url` / `key` back out and asserts equality with the inputs. 4/4 green. **i18n:** 4 new keys in the `settings.*` 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 build` clean (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.tsx` state 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. New `frontend/src/utils/sidebarLayout.ts` owns the localStorage round-trip (`sidebarOrder` + `sidebarHiddenSystemItems` keys), the `SIDEBAR_LAYOUT_CHANGED_EVENT` cross-tab refresh broadcast, and the `isExternalSidebarItemId` helper that distinguishes the new `ext-*` 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:** new `default_sidebar_order` setting (validated server-side at `backend/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`'s `useEffect` reads the admin default, filters it against the current `defaultNavItems` + 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-user `sidebarDefaultApplied_` localStorage flag so the default is one-shot — later user-driven changes aren't clobbered on every login. **Settings card:** `ExternalLinksSettings.tsx` is 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 holds `settings:write`), a **Reset** button (clears both `sidebarOrder` + `sidebarHiddenSystemItems` to 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 uses `flex-wrap` on 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 narrow `lg: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 IDs `card-updates`, `card-data`, `card-sidebar-links` are preserved so deep-links + the in-app `registerSettingsSearch` index still resolve. **Layout merge edge case:** the PR's refactor of `Layout.tsx::isHidden` accidentally dropped the dev-side notifications gate (`!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || settings?.user_notifications_enabled === false`) and its `advancedAuthStatus` useQuery. The merged shape keeps three gates in priority order — `hiddenSystemItemIds.includes(id)` first (cheapest, explicit user intent), then the array-aware `navPermissions` check from #1755 (granular `*:read_own` / `*:read_all` tiers), then the notifications-specific gate — so a user without advanced auth doesn't suddenly see the Notifications entry. **Backend:** `default_sidebar_order` settings 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 any `hiddenSystemItemIds` that isn't a `list[str]` with 422. **Tests:** 17 new backend cases in `test_sidebar_settings.py` pinning the validator (empty / JSON-array / JSON-object / mixed-types / hostile shapes). Frontend: 5 new `Layout.test.tsx` cases pinning the hide-toggle behaviour (hidden ID drops the entry, hidden ID for Settings is ignored — `settings` is non-hideable, eye-click round-trips through localStorage, `SIDEBAR_LAYOUT_CHANGED_EVENT` triggers a re-read across tabs) and 255 added/changed lines in `SettingsPage.test.tsx` covering the admin-default toggle and the eye-icon visibility column. **i18n:** new keys in the `externalLinks.*` 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 in `vitest.config.ts` to absorb the `userEvent.setup({delay: null})` cases in the heavier `SettingsPage` flows. Full vitest run green; ESLint clean; `npm run build` clean; 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_location` field. Spools now carry a `location_id` FK alongside the denormalized `storage_location` string (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/locations` page; 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=` (and `?location_id=__none__` for the unset bucket) still works for sharing or bookmarking. **Backend:** new `Location` model + `locations` table with case-insensitive `name_key` (LOWER(TRIM(name))) UNIQUE — concurrent creates on the same name resolve to a single 409 via the `IntegrityError` → re-fetch shape in `_create_location_or_get_existing`. CRUD at `/api/v1/inventory/locations`, all five routes gated with `RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ|UPDATE)`. Delete is blocked while `spool_count > 0` so the user can't strand spools. Single-write-path is `location_service::resolve_spool_location_fields()` — both the internal-mode and Spoolman-mode spool routes feed through it so `location_id` and `storage_location` can never drift. **Spoolman parity:** location names sync into the local catalog on `GET /spoolman/inventory/spools` via `maybe_sync_spoolman_locations`; rename cascades to every Spoolman spool via `client.rename_location`, with a per-spool PATCH fallback when the upstream's bulk endpoint isn't there (Spoolman <0.16 doesn't expose `PATCH /location/{name}` and returns 404/405). `get_distinct_locations` normalises both the older `list[str]` and the newer `list[dict]` Spoolman payload shapes. **Migration:** inline in `database.py::run_migrations` — creates the `locations` table (DATETIME for SQLite / TIMESTAMP for Postgres), adds `spool.location_id` FK + index, then backfills the catalog from existing free-text values (GROUP BY `LOWER(TRIM(storage_location))` so case variants like `Drybox 1` and `DRYBOX 1` collapse into one row). The legacy `name_key` backfill runs BEFORE the dedup INSERT so a pre-existing locations row with NULL `name_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-text `storage_location` with no `location_id` — surfaces the rare mis-link case to ops instead of silently leaving them out of catalog filters. **Rename safety:** Spoolman PATCH runs BEFORE `db.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-spool `location` field permanently diverged (the next sync recreates the old name as a duplicate catalog row). Legacy-row UPDATE matches `func.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.py` now emits `inventory_changed` on 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. The `useWebSocket` handler invalidates `inventoryLocationsQueryKey` on every such message so location counts stay in sync across tabs. **Performance:** the Spoolman→catalog sync used to fire on every `GET /spools` request, 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:** `SpoolFormModal` location dropdown sends `location_id` only (same shape in both inventory modes — no `spoolmanMode ? ... : ...` UI gate) and the `onCreateLocation` flow surfaces `ApiError.message` instead of a generic toast so 409 / 400 / 500 stay distinguishable. `LocationsModal` passes `isLoading` to `ConfirmModal` during delete so a mid-mutation cancel can't strand a toast on a dismissed dialog; Pencil / Trash icon buttons carry `aria-label` for SR announcement. **i18n:** new `locations.*` 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 across `backend/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_locations` shape guard × 4, `rename_location` bulk-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 when `auth_enabled=true`), and `frontend/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 on `spool_count>0`, etc.). Frontend `useWebSocket.test.ts` exercises the `inventory_changed` → invalidate `['inventory-locations']` round-trip. Full backend pytest 6025/6025 (67s with -n 30); frontend vitest 2141/2141; ruff clean; `npm run build` clean; 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_hours` under 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:** new `resolve_session_max_minutes(db)` helper in `backend/app/core/auth.py` reads 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-depth `SESSION_MAX_HOURS_HARD_CEILING = 720` clamps 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 in `backend/tests/integration/test_session_policy.py` split across three classes. `TestResolveSessionMaxMinutes` pins the clamping resolver — missing row, empty string, unparseable value, zero/negative, 1h minimum, 7-day passthrough, 30-day passthrough, above-ceiling clamp. `TestLoginRespectsSessionPolicy` decodes the JWT `exp` claim end-to-end and asserts the token returned by `/auth/login` honours the configured ceiling for the default-24h, configured-7d, and above-ceiling-clamp cases. `TestSettingsAPIExposesSessionMaxHours` round-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 in `settings.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 build` clean; 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_snippets` installs keep their previous behaviour until the per-VP toggle is explicitly enabled. When on, the scheduler still no-ops unless `gcode_snippets` are configured for the target printer model, so the effective semantics are "inject when enabled AND snippets exist." **DB column:** new `virtual_printers.gcode_injection BOOLEAN DEFAULT FALSE` with a branched `is_sqlite()` migration (SQLite `DEFAULT 0` / Postgres `DEFAULT FALSE`) matching the `queue_force_color_match` / `tailscale_disabled` precedent. **Multi-plate stamping:** the flag is set on every plate's `PrintQueueItem` inside 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_locked` change detector now compares `instance.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 dedicated `test_sync_from_db_restarts_on_gcode_injection_toggle`. **UI:** new toggle on `VirtualPrinterCard.tsx` (queue mode only — the toggle is hidden in archive/review/proxy modes since the feature is queue-specific), with the standard `updateMutation` save-on-click + toast on success, plus the `pendingAction='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 new `useEffect` clears the stale `gcodeInjection` state 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:** new `virtualPrinter.gcodeInjection.title` + `description` keys 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 in `test_virtual_printer.py` (queue items opt in / out based on the VP flag), 2 new integration cases in `test_virtual_printer_api.py` (create defaults to false, PUT round-trips the value), 1 new sync-restart case, plus updates to `_make_db_vp` so the change-detector test fixture carries an explicit `False` rather than relying on `MagicMock` truthiness. 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 build` clean. - **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_history` table — long format per `(printer_id, sensor_kind, value, target, recorded_at)` with `sensor_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 from `state.temperatures` (already normalised across model field-aliases like `nozzle_temper`, `left_nozzle_temper`, `right_nozzle_temper`, `chamber_temper` by the MQTT parser) rather than re-parsing `raw_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.** New `printer_sensor_history_retention_days` setting (default 30, mirroring the existing `ams_history_retention_days` knob); 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=` returns one series per requested kind with `data: [{recorded_at, value, target}, ...]` + min / max / avg stats per series. `DELETE /printer-sensor-history/{printer_id}?days=` for explicit purge. Both gated behind the new explicit `PRINTER_SENSOR_HISTORY_READ` scope (separate from `AMS_HISTORY_READ` so 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 lucide `LineChart` icon 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 new `HeaterHistoryModal` with that kind pre-selected and `e.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.** Same `var(--bg-*)` / `var(--text-*)` theme variables as `AMSHistoryModal` so 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 recharts `LineChart` plotting 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 in `printers.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 in `test_printer_sensor_history.py` (per-sensor series + stats; `kinds` query filter; `hours` window clamp; DELETE removes only the targeted printer's old rows). 6 new frontend cases in `HeaterHistoryModal.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 backend `pytest -n 30` 6226/6226 in 92 s; full frontend vitest 2176/2176; ruff clean; `npm run build` clean; 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 | None` populated from bit 18 of the top-level `print.cfg` hex string on every push_status (`bambu_mqtt.py::_process_message` ~line 1037). New module-level helper `parse_ams_filament_backup_from_cfg()` returns `None` on absent / non-hex / non-string input so old-protocol families (A1 / A1 Mini, which emit no `cfg`) 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:4961` `SetAutoRefillEnabled(get_flag_bits(cfg, 18))`) and a live H2D ON/OFF capture during this work — the cfg flips exactly between `C0340FC219` (bit 18 set, ON) and `C0340BC219` (bit 18 clear, OFF), only the fifth nibble changing. **Backend — toggle.** New `POST /printers/{id}/ams-backup?enabled=` route gated on `Permission.PRINTERS_CONTROL` calls `client.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": , "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 other `print_option` flags, so we never clobber other state. Optimistic local state update lives inside `_set_print_option` immediately 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.** New `GET /printers/{id}/inventory-remain` route exposes the same `Map` the dispatcher uses (via the existing `_build_inventory_remain_overrides` helper), 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 to `max(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_dict` and the `PrinterStatusResponse` Pydantic schema both extended with the new field; the printer's REST `/printers/{id}` response carries `ams_filament_backup`. `state.ams_filament_backup` added to the `status_key` dedup tuple in `main.py:1101` so 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 (`Repeat` from lucide-react) on `bg-blue-500/20`; OFF = dim icon on `bg-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 the `printers.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) and `test_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 in `test_printer_offline_notification.py` and `test_printer_manager_status_broadcast.py` extended with `ams_filament_backup=None` to match the new `status_key` field; full pytest confirms no other stub needed updating. **What this does NOT do.** Cover A1 / A1 Mini: those models emit no `cfg` field 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 adding `ams_filament_backup` doesn't change what they emit. Full backend pytest 6217/6217; full frontend vitest 2170/2170; ruff clean; `npm run build` clean; ESLint clean. ### Fixed - **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:2135` wrote `state.total_layers = int(data["total_layer_num"])` unconditionally on every push containing the field. P1S firmware (observed; matches the pattern other models reset `layer_num` / `progress` via at print end) pushes a `total_layer_num: 0` frame 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_layers` was 0. The existing `_last_valid_layer_num` guard at line 2127 covered the same race for `layer_num` but the equivalent guard for `total_layers` was never added. **Bug B — `usage_tracker.py:1129-1137` dumped-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 gets `total_weight - sum_previous`). When per-layer 3MF data is unavailable (force-started prints often lack it) AND `total_layers == 0` (Bug A had just fired), the linear branch silently produced `segment_grams = 0.0` for 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 reports `remain=-1` after the empty event so the percent-delta calculation rejected it, and (2) the second spool's tray key had already been added to `handled_trays` by 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_num` shape: only overwrite `state.total_layers` when the incoming `total_layer_num` is 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_start` block at line ~3132 (right next to the existing `state.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: try `state.total_layers` first (the canonical source), then `last_layer_num` (the print's last-valid layer captured at completion time, already threaded into `_track_from_3mf` as a parameter for the `last_progress` partial-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 with `state.total_layers=0` and `last_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 7 `TestTrayChangeSplit` cases (including the precise-per-layer-gcode happy path and the `total_layers=100` linear fallback regression at line 1045) still green — they use a positive `total_layers` so 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_3mf` returns 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) > 1` gate). 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_layers` now 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 existing `state.layer_num = 0` reset) re-zeroes it cleanly on every new print, so the previous print's total still can't bleed into the next. Audited every consumer of `state.total_layers` across 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 at `bambu_mqtt.py:2206`): none distinguishes "no active print" by `total_layers == 0` — they all check `state.state` for 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 backend `pytest -n 30` 6222/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=true` with 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_key` at `print_scheduler.py:1161`) that puts inventory-bound spools in tier 0 (sorted by `label_weight - weight_used` for internal mode or Spoolman's `remaining_weight` for Spoolman mode) and MQTT-only spools in tier 1 (sorted by the printer's `remain%` field) — but it only runs on queue items dispatched without a pre-set `ams_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 by `remain%` and had no notion of inventory grams. For two RFID Bambu spools both reporting `remain=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_printer` at `print_scheduler.py:867` coerces `prefer_lowest=False` when `status.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 exported `preferLowestSortKey(f, inventoryByTrayId)` in `amsHelpers.ts` mirrors the backend banding exactly — inventory-bound spools sort to tier 0, MQTT-only to tier 1, with backend-matching slot tie-break (`amsId * 4 + trayId` for regular AMS, `1000 + (amsId - 128) * 4 + trayId` for AMS-HT, `10_000` for external / VT so external always sorts LAST regardless of negative raw `ams_id`). All seven frontend sort sites switched to it: `autoMatchFilament` + 2 sites in `useFilamentMapping.ts::computeAmsMapping` (top-level + nested for non-unique tray_info_idx) + 2 sites in `useFilamentMapping.ts::useFilamentMapping` (the hook variant) + 2 sites in `useMultiPrinterFilamentMapping.ts` (`computeMappingWithOverrides`, `computeMatchDetails`) + `autoConfigurePrinter`. Hook signatures grew an optional `inventoryByTrayId?: Map` 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 used `amsId * 4 + trayId` for all slots, which gives `ams_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 exported `effectivePreferLowest(setting, amsFilamentBackup)` mirrors the backend gate rule (`!setting → false; backup === false → false; otherwise true`). PrintModal computes it for the single-printer flow at `index.tsx:380`; `useMultiPrinterFilamentMapping` computes it per-printer inside the `printerResults.map` (different printers in the same dispatch can have different backup states, so a global flag would be wrong); PrinterSelector's `InlineMappingEditor` and `FilamentMapping.tsx`'s standalone editor (the per-AMS slot dropdown) both wired through. The standalone editor previously had NO `preferLowest` awareness at all — its auto-suggestion could disagree with what would actually be dispatched. Closed in this change. **Inventory map — single source of truth.** New `GET /printers/{id}/inventory-remain` endpoint (see Added entry above) exposes the same `_build_inventory_remain_overrides` result the dispatcher uses, so PrintModal and `FilamentMapping.tsx` get the same `Map` 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 via `useQueries` keyed on `'printer-inventory-remain'`, 30 s staleTime, no fetch for unselected printers. An earlier attempt derived the map client-side from `/inventory/assignments` directly — that endpoint only reads the internal-mode `SpoolAssignment` table, 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 key `settings.preferLowestFilamentBackupNote` translated 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) and `test_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` (`computeAmsMapping` inventory × 3 + `effectivePreferLowest` gate × 5 + slot-priority banding regression × 1) and `PrinterSelector.test.ts` (`autoMatchFilament` inventory × 3). Existing 56 + 27 cases still green — backwards-compat preserved by optional new params. Full backend `pytest -n 30` 6217/6217 in 74s; full frontend `vitest` 2170/2170 in 29s; ruff clean; `npm run build` clean; eslint clean; i18n parity green. - **H2C nozzle pick from Bambu Studio not preserved on the dual-nozzle rack variant (O1C2) — printer auto-picked the last matching nozzle instead (#1780, reported by @mkoreen)** — The reporter (H2C with the rack-swap "dual nozzle variant" model code O1C2; 7 nozzles registered across two extruders: R1/R2 high-flow 0.4, R3/R4 standard 0.4, plus three other diameters) noticed that picking a specific nozzle in Bambu Studio (e.g. "use R1") had no effect — the H2C would consistently load R2 for HF prints and R4 for standard prints. Root-cause traced from the printer's reported `device.nozzle.info[]` state + the Bambu Studio source: BambuStudio's `project_file` MQTT command for O1C2 carries two extra fields — `nozzle_mapping` (a `list[int]` of per-filament physical nozzle position IDs, populated from a prior `get_auto_nozzle_mapping` round-trip with the firmware) and `nozzles_info` (a `list[dict]` of per-extruder rack metadata: `id`/`type`/`flowSize`/`diameter`). The VP intake at `virtual_printer/manager.py:564-574` only captured five fields out of the slicer's project_file dict (bed_leveling / flow_cali / vibration_cali / layer_inspect / timelapse) and dropped everything else, including these two. Without `nozzle_mapping` on the dispatched project_file, the H2C firmware fell back to its auto-pick rule ("any nozzle matching diameter + flow class") and deterministically landed on the last matching slot in the rack — which is why R1 selections always became R2 and R3 selections always became R4. **Fix:** carry both fields through the full intake → queue → dispatch path. `_add_to_print_queue` reads `nozzle_mapping` + `nozzles_info` out of the captured slicer opts, normalises a stringified-JSON or already-parsed shape to the same canonical JSON-string representation, and stamps both on the PrintQueueItem inside the multi-plate loop (so a multi-plate "Send All" preserves the nozzle pick across plates, mirroring `gcode_injection` / `filament_overrides` per-plate stamping from #1697 / #1188). New nullable TEXT columns `nozzle_mapping` / `nozzles_info` on `print_queue` — non-branched ALTER (same as `ams_mapping` / `filament_overrides` precedent at `database.py:944/955`). The dispatcher reads the JSON strings off the queue item, parses them back to list/dict, and includes them on the published `project_file` command as parsed JSON values (not strings — the wire shape matches BS's, same convention as `ams_mapping` / `ams_mapping2`). Dual-nozzle gate at `bambu_mqtt.py::start_print()` keeps the fields off single-nozzle dispatches as defense-in-depth (`is_dual_nozzle` runtime flag already established by `device.extruder.info[]` len ≥ 2). **Fail-open on malformed JSON:** an unparseable column value logs a WARNING and omits the field — firmware then runs the same auto-pick path that was the pre-fix behaviour, never a worse one. **No model gate elsewhere:** every other model omits these fields from its project_file, so the pass-through is a transparent no-op on X1C / P1S / A1 / H2D / X2D. **API surface:** `PrintQueueItemResponse` parses both fields back to `list[int]` / `list[dict]` so any future "edit print → nozzle" UI can read+round-trip them; `PrintQueueItemUpdate` accepts them and the route handler serialises to JSON for storage (same shape as `ams_mapping`). **Tests:** 3 new cases in `test_virtual_printer.py::TestVirtualPrinterInstance` (capture round-trip, NULL-on-omitted-fields, per-plate stamping on multi-plate) and 6 new cases in `test_bambu_mqtt.py::TestStartPrintNozzleMappingDispatch` (dual-nozzle injection both fields, single-nozzle no-emit even when set, dual-nozzle no-fields no-op, partial-only mapping passthrough, malformed JSON logs + dispatch continues, empty-string treated as absent). 1 line update in `test_printer_manager.py::test_start_print_calls_client` to add the two new kwargs to the `assert_called_once_with` matcher. Full backend `pytest -n 30` 6176/6176 in 86.67s; ruff clean; `npm run build` clean; vitest 2158/2158; i18n parity green. **Scope:** O1C2 (the H2C dual-nozzle-rack variant) is the only Bambu model with a rack-swap mechanism where the firmware can choose between multiple physical nozzles per side, so the observable fix lands there. H2D / X2D dual-extruder routing was never affected — those carry filament-to-extruder mapping through `ams_mapping2` (ams_id 254/255), which Bambuddy already forwards correctly. No DB migration on Postgres-only side; no permission change, no i18n keys, no frontend changes (a "pick a different nozzle from queue/archives" UI is reasonable follow-up scope but isn't required to close this bug — the slicer's pick now rides through, which is the reporter's primary expected behaviour). - **Docker installer fails on the default `/opt/bambuddy` path with "Permission denied" (#1774, reported by @jmoore-skild)** — `install/docker-install.sh::create_install_dir` (line 252) ran `mkdir -p "$INSTALL_PATH"` without sudo while `DEFAULT_INSTALL_PATH="/opt/bambuddy"` (line 32) — root-owned on every Linux distro. `set -e` at line 20 then aborted the whole run before docker compose could ever pull the image. Anyone following the documented `curl … | bash` flow as a normal user hit this immediately. The native installer at `install/install.sh:361` already handles the same situation correctly with `sudo 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 `~/bambuddy` since the Docker installer only writes `docker-compose.yml` + `.env` on the host (real app data lives in named volumes), but `install/update.sh:4` and `install/update_macos.sh:4` both default `INSTALL_DIR` to `/opt/bambuddy`, and `install/README.md:274` documents `INSTALL_DIR=/opt/bambuddy sudo ./update.sh` for 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 in `create_install_dir`, not the default path. **Fix:** `create_install_dir` now tries `mkdir -p "$INSTALL_PATH" 2>/dev/null` first — the cheap no-sudo path covers `--path ~/bambuddy`, `--path /srv/bambuddy`, and any other writable target — and only falls back to `sudo 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 write `docker-compose.yml` and `.env` into a root-owned dir as the unprivileged invoking user, kicking off a cascade of EACCES failures further down. Idempotent on re-run (the second `mkdir -p` succeeds against the now-owned dir, no second sudo prompt). `set -e` survives the redirected stderr because the `if !` 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 -e` survives intact. **What this does NOT change:** the default install path stays `/opt/bambuddy` for parity with `install.sh` / `update.sh` / the documented update flow; the Windows mirror at `install/docker-install.ps1` already uses `$env:USERPROFILE\bambuddy` (per-user convention on Windows) and is untouched. No docs change required — `install/README.md` and 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 that `POST /api/v1/makerworld/import` and `POST /api/v1/makerworld/resolve` returned `{"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_enabled` in `backend/app/core/auth.py:1414` deliberately returns `current_user=None` for API-keyed callers — the comment at line 1408 makes this explicit and points at `cloud.py` for 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 as `resolve_api_key_cloud_owner` (cloud.py:128-160) — used by `slicer_presets.py:491` and `library.py:3871`. The MakerWorld routes were missing the wire-up. **Fix:** Three routes get the extra `api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner)` parameter — `get_status`, `resolve_url`, `import_instance` — and each resolves `cloud_token_user = current_user or api_key_cloud_owner` before calling `get_stored_token` / `_build_service`. `import_instance` additionally uses `cloud_token_user.id` for the `owner_id` argument to `save_3mf_bytes_to_library` (which translates to `LibraryFile.created_by_id`), so library rows imported via API key are now attributed to the key's owner instead of staying NULL. `/recent-imports` is unchanged — it only uses `current_user` as a permission gate (`_ = current_user`) and never touches the cloud token. The fix preserves fail-closed semantics for keys *without* the `can_access_cloud` flag: `resolve_api_key_cloud_owner` already fences on `api_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_library` per `_APIKEY_SCOPE_BY_PERMISSION` in `core/auth.py`) AND the orthogonal `can_access_cloud` flag (separate column on the `api_keys` table). The fix doesn't change that surface — it just stops dropping valid `can_access_cloud=True` keys on the floor. **Tests:** 6 new cases in `backend/tests/integration/test_makerworld_apikey_auth.py` pinning the full surface — API key with `can_access_cloud=True` + owner-has-token → `/status` reports `has_cloud_token=True`, `/resolve` builds the service with the owner User (asserted on the `_build_service` mock's call args), `/import` succeeds end-to-end and the resulting `LibraryFile.created_by_id` matches the API-key owner; API key with `can_access_cloud=False` → status still reports `has_cloud_token=False` (no widening) and import-row's `created_by_id` stays NULL; JWT-authenticated parity check confirms the existing user-session flow is unchanged by the added `Depends`. 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 renders `Metadata/plate_N.png` when invoked headlessly with `--slice --export-3mf`.** That render is a separate code path triggered by the `--export-png` flag, which is mutually exclusive with `--export-3mf` and additionally requires a working display backend (BS 02.07.x's bundled GLFW is hard-locked to Wayland — even `XDG_SESSION_TYPE=x11` + `GDK_BACKEND=x11` + `QT_QPA_PLATFORM=xcb` don'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-stripped `Cube-MegaS.3mf` through both sidecars: both produced `.gcode.3mf` with 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:** New `backend/app/services/plate_thumbnail.py` renders the missing thumbnails server-side after the slice returns. `inject_plate_thumbnails_if_missing(threemf_bytes)` parses the sliced zip, finds every `Metadata/plate_N.gcode` entry that doesn't have a matching `plate_N.png`, loads `3D/3dmodel.model` via 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 as `stl_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 both `backend/app/api/routes/library.py` slice paths (library-file slice at line 3593 + archive re-slice at line 3718) via `result = result._replace(content=inject_plate_thumbnails_if_missing(result.content))` immediately before `out_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 imports `networkx` (scene-graph traversal) and `lxml` (model.xml parse) lazily inside the 3MF code path — both added to `requirements.txt` because they aren't strict trimesh transitives but the loader fails at runtime without them (`ModuleNotFoundError`). **Tests:** 7 new cases in `backend/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 no `3D/3dmodel.model` returns input unchanged; non-zip input returns input unchanged; idempotent on second pass. **Verified end-to-end:** running `inject_plate_thumbnails_if_missing` against 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 valid `Metadata/plate_1.png` + `Metadata/plate_1_small.png` containing 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-run` in `Dockerfile.bambu-studio` were 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 extracts `plate_1.png` from 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 the `onSuccess` handler closed the confirmation modal and called `queryClient.invalidateQueries({ queryKey: ['localPresets'] })` without awaiting it. The global QueryClient default `staleTime: 1000 * 60` (App.tsx:78) doesn't block `invalidateQueries` from 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 showed `DELETE /api/v1/local-presets/42 → 200` followed by two `→ 404` for the same id within 4 seconds. **Fix:** Add an optimistic `queryClient.setQueryData(['localPresets'], …)` in `frontend/src/components/LocalProfilesView.tsx::deleteMutation.onSuccess` that filters the deleted row out of the cached list synchronously, then leaves the existing `invalidateQueries` calls in place to reconcile any drift. Row disappears the instant the DELETE returns 200, no re-click window. The same import path's `importMutation` doesn'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 build` clean; existing `LocalProfilesView.test.tsx` suite 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-155` reimplemented the search filter inline and only matched `material`, `subtype`, `brand`, `color_name`, and `note`. The main Inventory page delegates to the shared `filterSpoolsByQuery` helper in `frontend/src/utils/inventorySearch.ts:7`, which additionally matches `String(spool.id)`, `slicer_filament_name`, and `storage_location`. SpoolBuddy had diverged. **Fix:** replace the inline filter with a single call to `filterSpoolsByQuery(list, searchQuery.trim())`. Both inventory modes (internal via `getSpools`, Spoolman via `getSpoolmanInventorySpools`) return the same `InventorySpool` shape, so this covers both paths in one drop. SpoolBuddy now matches Bambuddy's search behaviour across all eight fields. **Tests:** new `SpoolBuddyInventorySearch.test.ts` with 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. Existing `inventorySearch.test.ts` ID matching test (#1336) still green. ESLint clean; `npm run build` clean. 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 `/files` directly and the backend API accepted their requests. Root cause is broader than reported: `frontend/src/components/Layout.tsx::navPermissions` mapped `files → 'library:read'`, `archives → 'archives:read'`, `queue → 'queue:read'` — the LEGACY permission flags — but the default Operators group at `backend/app/core/permissions.py:368-380` is seeded with the GRANULAR variants only (`ARCHIVES_READ_OWN.value`, `QUEUE_READ_OWN.value`, `LIBRARY_READ_OWN.value`). The migration path at `backend/app/core/database.py:3034-3041` also flips legacy `*:read` → `*:read_own` on 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 get `ALL_PERMISSIONS` which 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:** `navPermissions` now accepts `Permission | Permission[]` and the three affected resources list all three tiers (`*:read`, `*:read_own`, `*:read_all`). The `isHidden` check 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.ts` Permission 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_all` can now do so without re-adding the same type entries. **Tests:** 5 new cases in `Layout.test.tsx::'Sidebar gate accepts granular read tiers (#1755)'` — Files visible with only `library:read_own`, Files visible with only `library:read_all`, Archives visible with only `archives:read_own`, Queue visible with only `queue:read_own`, and the negative case (`printers:read` only — none of Files / Archives / Queue render). 22/22 Layout vitests green; ESLint clean; `npm run build` clean. 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_offline` toggle has shipped since the notifications feature landed: schema field, DB column, `notification_template.py` entry, and the dispatcher `NotificationService.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's `gcode_state=FAILED` report 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 under `main.py::on_printer_status_change` or 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()` and `bambu_mqtt.py::check_staleness` after the 30s STALE_RECONNECT_COOLDOWN) route through `_on_status_change` already and reach `on_printer_status_change`; the handler just didn't act on the disconnect edge. **Fix:** edge detection in `on_printer_status_change` watches `state.connected` against 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.0` then re-checks `printer_manager.is_connected(printer_id)` — only fires the notification if the printer is still offline. **Why 60s debounce:** sized against `bambu_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_tasks` in the finally block whether the notification fired, the printer reconnected, or the task was cancelled mid-await. **No symmetric `on_printer_online` event:** 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 reporting `gcode_state=FAILED` for 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's `bambu_mqtt.py:3039` explicitly suppresses `is_new_print` for PAUSE → RUNNING to prevent duplicates when resuming from pause), but that's a separate scope from offline-detection. **Tests:** 9 new cases in `test_printer_offline_notification.py` split across two classes. `TestMaybeNotifyPrinterOffline` pins 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. `TestOfflineEdgeDetection` pins the edge logic inside `on_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. - **Completion notification reported the whole project's duration and material usage when only one plate of a multi-plate 3MF was printed (#1785)** — Reporter (H2D) noticed the Discord on-print-complete message stated the full multi-plate project's totals (e.g. "6h 12m" / "370 g") even though only a single plate had been started, while every Bambuddy surface (print queue card, archive card, statistics) correctly showed the per-plate values. Root cause traced to the 3MF parser at `services/archive.py:200-264`, which sums per-plate `prediction` (slicer time estimate) and `weight` across every `` of a multi-plate file and stores those file-level totals on the `PrintArchive.print_time_seconds` / `PrintArchive.filament_used_grams` columns. That summing was added by #1593 to fix the archive card under-reporting on multi-plate files, and is correct for the archive-level "whole project" headline. The queue UI already re-reads the 3MF per-plate at `print_queue.py:272-285` (using `extract_filament_usage_from_3mf` / `_extract_print_time_from_3mf`) and substitutes the plate's actual values — which is why everything inside Bambuddy displays per-plate correctly. The notification path at `main.py::_background_notifications` read the archive's columns and `extra_data.filament_slots` directly with **no plate-aware override**, so the dispatched template variables (`{{duration}}`, `{{filament_grams}}`, `{{filament_details}}`) consistently rendered the project-wide sum. For material grams this was unconditionally wrong on multi-plate single-plate prints; for duration it depended on whether the real elapsed `actual_time_seconds` was populated (the `actual_time_seconds or print_time_seconds` fallback chain only landed on the summed estimate when the timestamps weren't usable). **Fix:** new `_scope_notification_archive_data_to_plate(archive_data, file_path, plate_id, status, progress, base_dir)` helper in `main.py` mirrors what the queue UI does — when `plate_id` is set on the just-completed print, re-read the 3MF, sum the plate's `` entries for the actual grams, read the plate's `` for the estimate, and replace `archive_data["actual_filament_grams"]` + `archive_data["print_time_seconds"]` + `archive_data["filament_slots"]` accordingly. `notify_plate_id` is captured from the existing `_print_plate_ids` register at the same point that already pops it (around `main.py:4183`), so no extra bookkeeping is added to the print-start path — the queue dispatcher and direct-Print path both already register plate_id there. **Partial-print scaling preserved:** the helper applies the same `progress / 100` scale factor to the per-plate grams + per-slot weights as the pre-existing summed-totals branch did, so a 50%-cancelled plate-2 print still reports "half the plate's grams," not the whole plate. **Fail-open on every error path:** missing `plate_id` (single-plate file / archive-only flow), missing `archive.file_path`, the 3MF file having been deleted between print completion and notification firing, a corrupt zip, or a plate index outside the file's range — all return `archive_data` unchanged so the notification still ships with the project-level numbers it would have shown before this fix. Same defensiveness shape as the helper-loaders the queue route relies on. **Hoisted `extract_print_time_from_3mf` into `utils/threemf_tools.py`** so the notification path can reuse the queue UI's logic without importing from a routes module (the route's `_extract_print_time_from_3mf` becomes a one-line alias). Identical signature + return shape, so the queue's existing call sites keep working without changes. **Tests:** 10 new cases. `test_threemf_tools.py::TestExtractPrintTimeFrom3mf` (7 cases): plate-N prediction returned for plate_id=N, first plate when no plate_id passed, None for plate_id outside range, None for unparseable prediction, None for missing slice_info / invalid zip / missing file. `test_notification_plate_scope.py::TestScopeNotificationArchiveDataToPlate` (10 cases): plate-2 of 3 collapses summed 370g/3h into plate's 120g/60min; plate-1 and plate-3 scope correctly; partial-print at progress=50 halves grams + per-slot weights but keeps full slicer estimate; no plate_id / no file_path / missing file / corrupt zip / out-of-range plate_id all return the input unchanged so the notification still sends; single-plate file with plate_id=1 is a clean no-op (the parser's sum already collapses to plate-1's values, no double-scaling). Full backend `pytest -n 30` 4086/4086 in 50s; ruff clean. **Scope clarification:** the archive card / project rollup / statistics surfaces stay on the summed totals (the original #1593 contract) — only the completion notification path now plate-scopes, mirroring the queue card precedent. Print Logs entries continue to use the per-run filament helper (#1378 / #1390) which already reads from `usage_results` + scales by progress, so this fix doesn't touch them. ## [0.2.4.7] - 2026-06-14 ### Added - **Bambu Lab A2L support (#1684)** — Internal model code `N9`, serial prefix `26A19` (5 chars, same shape as H2C's late `31B8B`). Capabilities resolved from BambuStudio's `resources/profiles/BBL/machine/Bambu Lab A2L.json` cross-checked against Bambu's official A2L specs page: linear rail, single FDM extruder + integrated cutter/plotter head (the BambuStudio `use_double_extruder_default_texture: true` flag covers the dual TOOL HEADS, not dual filament extrusion — A2L must NOT route AMS to the deputy slot or firmware rejects with 07FF_8012). Specs page also confirms NO Ethernet (Wi-Fi 2.4 GHz 802.11 b/g/n only), `Low-Rate-Kamera` on the chamber-image protocol (port 6000, NOT RTSP:322), no heated chamber. **Registry updates**: `PRINTER_MODEL_MAP` + `PRINTER_MODEL_ID_MAP` + `LINEAR_RAIL_MODELS` in `utils/printer_models.py`; `MODEL_TO_API_KEY` + `API_KEY_TO_DEV_MODEL` + `API_KEY_TO_WIKI_PATH` in `firmware_check.py` (wiki path follows the established `/en/a2l/manual/a2l-firmware-release-history` pattern; the existing 404 handling in `_fetch_all_versions_from_wiki` makes this safe to ship before Bambu publishes the page); `VIRTUAL_PRINTER_MODELS` + `MODEL_SERIAL_PREFIXES` in `virtual_printer/manager.py` (prefix `26A19A` with the same revision-letter padding as X2D's `20P90A`); `MODEL_PRODUCT_NAMES` in `virtual_printer/mqtt_server.py`; `mapModelCode` + Add-Printer / Edit-Printer model dropdowns in `PrintersPage.tsx` (new "A2 Series" optgroup); `mapModelCode` in `SpoolBuddyAmsPage.tsx`. **Camera and dual-nozzle code paths need no edits**: `supports_rtsp()` correctly falls through to chamber-image for A2L because `N9` is neither in the internal-code RTSP set nor does the display name match the X1/X2/H2/P2 prefix tuple; `is_dual_nozzle_model()` correctly returns False because A2L is not in `DUAL_NOZZLE_MODELS`. The cutter/plotter capability surfaces in MQTT push fields Bambuddy doesn't yet model; ignored for v1, will surface as a follow-up only if a real-world A2L bundle reveals a confusing UI state. **Tests**: 12 new cases in `test_printer_models.py::TestA2LModel` pinning every dimension — rod type, model-id round-trip, both ethernet directions, both camera-port directions, the explicit non-dual-nozzle guard (regression guard for the BambuStudio profile flag misread), set membership in `LINEAR_RAIL_MODELS` and exclusion from `CARBON_ROD_MODELS` / `STEEL_ROD_MODELS`. - **One-shot `device.*` identification probe in MQTT push parser (#1684 enabler)** — Adding support for a new Bambu printer model needs the internal model code the firmware sends in MQTT `device.dev_model_name` (e.g. A1 is `N2S`, H2C is `O1C`, X2D is `N6`). The field arrives on every push but Bambuddy never logged it, so even a debug-enabled support bundle from a new-model user (A2L on #1684 was the case that surfaced this) gave us no way to identify the model — `get_version` was also missing because the printer disconnected right after the request topic subscription, which is a separate firmware quirk. **Fix:** at the top of the existing `device.*` parsing block in `bambu_mqtt.py`, emit one INFO log per client session dumping `dev_model_name` / `dev_product_name` / `dev_id` / `project_name` if any are present; otherwise fall back to `device.keys()` so a future Bambu rename (e.g. `model_name` without the `dev_` prefix) still surfaces. INFO level so the line lands in every support bundle, not just debug-enabled ones; one-shot via a `_device_id_logged` flag matching the existing `_nozzle_fields_logged` pattern at line 2095 — no spam at every push_status. 3 unit tests in `TestDeviceIdentificationProbe` pin the one-shot behaviour, the known-id-field path, and the keys-fallback path. Full `test_bambu_mqtt.py` suite 281 / 281 green; ruff clean. Once this ships, a new-model issue self-resolves from the first bundle — no second round of "please enable debug and reupload" required. - **Re-print / Schedule modal: cross-extruder AMS slot picks on dual-nozzle (#1722, reported by @privatsturm)** — On a dual-nozzle setup (e.g. H2D with AMS A+C wired to the left extruder and AMS B wired to the right), the per-filament slot dropdown in the Re-print and Schedule modals used to hide every slot whose extruder didn't match the filament's slicer-assigned nozzle. A filament the slicer had assigned to the left extruder would only let the user pick from A or C; a right-assigned filament could only pick from B. Users who'd intentionally loaded the required filament into the "other" AMS — for example, AMS B (right side) carrying a colour the slicer had planned to print on the left — couldn't select it, even though the printer can physically run that AMS through its wired extruder. Three slice-output diffs (BambuStudio Desktop, OrcaSlicer Desktop, Bambuddy sidecar) all produced identical filament_map values for the same source 3MF, so the slicer wasn't the source of the asymmetry — Bambuddy's UI filter was. **Behaviour:** every loaded slot is now offered for every filament row in the Re-print and Schedule modals' specific-printer flow, regardless of which extruder it's wired to. The L/R badge on the filament row stays as a visual hint to what the slicer planned; the dropdown now trusts the user to pick based on their physical setup. Single-nozzle printers and FTS-equipped setups are unchanged — both short-circuited the filter already and continue to. Printer firmware accepts or rejects the resulting `ams_mapping` at start-print, so a physically-impossible pick fails loudly rather than silently. **Implementation:** `FilamentMapping.tsx:248-254` carried a guard `f.extruderId === item.nozzle_id` on the slot dropdown's `loadedFilaments` filter; the guard is now removed. The single-nozzle and FTS short-circuits stay. **Tests:** `'still applies the per-nozzle filter when FTS is null'` flipped to `'offers cross-extruder slots in the dropdown without FTS (#1722)'` — same scenario (no FTS, AMS 0 on right, filament asking for left), but now asserts both slots ARE listed. The FTS-installed case (#1162) and the rest of the FilamentMapping suite stay green. Backend untouched; no schema, no i18n. 5/5 FilamentMapping vitests green; 1043/1043 full component sweep green; frontend build clean. - **Support bundle now includes redacted cached push_status per connected printer** — The existing support bundle (`GET /support/bundle`) shipped `support-info.json` + `bambuddy.log` — useful for triage, but missing the one thing that consistently blocks per-model work: the raw shape of the printer's MQTT push_status payload. Bambu firmware ships per-model config in a different shape for every family — AMS Backup detection was deferred in `85fbd7fc` because the H2D's bit-26 of `print.cfg` doesn't translate to the X1C / P1S / P2S layout and we had no ground-truth samples to map them; the same gap surfaces every time a `vt_tray` / `vir_slot` / `mapping` shape varies across firmware (the P2S `tray_now` fix, the H2D `vir_slot` parsing, the round-5 `vt_tray` overlay fix from #1622 last week all needed wire samples to land). **What's new:** the bundle now contains a `push-status/printer-{i}.json` file per connected printer, indexed against `support-info.json["printers"]`. Each file carries `{model, firmware_version, captured_at, raw_data}` where `raw_data` is the live cached push_status from `BambuMQTTClient.state.raw_data`. Disconnected printers (no MQTT state, or `raw_data` empty) are skipped — there's nothing to capture and an empty file just adds noise. **Redaction (two-pass):** a structural pass via the new `_redact_raw_push_status` helper drops user-private top-level keys anywhere in the tree (`subtask_name`, `gcode_file`, `gcode_file_prepare_percent`, `subtask_id`, `task_id`, `project_id`, `design_id`, `profile_id`, `model_id`, `gcode_state`) — Bambu's per-print filename/cloud-ID surface — and rewrites every `net.info[*].ip` entry to `"0.0.0.0"`, mirroring the LAN-topology leak fixed for the virtual-printer bridge in #1429. **What's deliberately preserved:** `print.cfg`, `print.option`, `ams.*`, `vt_tray`, `vir_slot`, `mapping`, `ams_extruder_map`, hardware fields (`nozzle_diameter`, temperatures, layer counters). These are the fields per-model work depends on. The structural pass then runs through `sanitize_log_content` with the same DB-derived `sensitive_strings` map the log path uses (printer names, serials, IPs, access codes, usernames, Bambu Cloud email) — belt-and-suspenders against any user-named string that leaked into a tray UUID or a sub-brand field. The redactor returns a NEW dict and never mutates the live `state.raw_data` (the dispatcher reads it on every tick; mutation would race the next push). **Why always-on instead of opt-in:** the bundle endpoint is already gated on "debug logging must be enabled" — generating the bundle is an explicit user act, the file downloads to the user's machine before they choose to send it, and forcing a second toggle adds friction without changing the threat model. Once a handful of bundles arrive from new-model users we'll have what we need to unblock AMS Backup awareness in the print-queue deficit check, plus future per-model shape variance. **Tests:** 5 new unit cases in `test_support_helpers.py::TestRedactRawPushStatus` pin the contract — drops the 9 user-private keys, rewrites `net.info[*].ip` while preserving `mask` siblings + sibling `net` keys, preserves `print.cfg` / `ams` / `vt_tray` / `vir_slot` / `mapping` / `ams_extruder_map`, does not mutate input, handles non-dict input gracefully (returns `{}` for None / list / str). Full support test surface 79/79 green (`test_support_helpers.py` + `test_support_api.py`); full backend suite 5937/5937 green with `-n 30`; ruff clean across the backend; frontend untouched but rebuild + i18n parity confirmed clean per `feedback_run_all_ci_checks`. No migration, no new i18n keys, no schema changes, no frontend changes. - **Windows installer build pipeline scaffolded** — Lays down the infrastructure for producing a self-contained Bambuddy Windows installer `.exe` that doesn't require Python, Node, or any other runtime on the target machine. The installer ships an embedded Python 3.13 distribution (matching the Dockerfile's `python:3.13-slim-trixie`), the pre-built React bundle, NSSM (service supervisor), and ffmpeg — everything Bambuddy needs to run end-to-end on a stock Windows 10/11 box. **Architecture:** install target `C:\Program Files\Bambuddy\`, data target `C:\ProgramData\Bambuddy\data\` (preserved on uninstall so reinstalls keep the database + archives), service registered via NSSM running as `LocalSystem` with autostart on boot (LocalSystem is required because the Virtual Printer feature needs to bind 322 / 990 / 8883, all privileged ports on Windows). Browser is the UI — Start Menu shortcut opens `http://localhost:8000`, no Tauri / Electron launcher in v1, which matches how every other Bambuddy platform already works. **Why this shape over a PowerShell `install.ps1`:** the script approach was tried first and abandoned. Each failure across the Windows host fleet is environmental drift (Python version mismatches, execution-policy variants, antivirus heuristics, missing MSVC runtimes, OneDrive-redirected `%APPDATA%`, ARM64 vs x64, PowerShell 5.1 vs 7.x semantics) — a script can't insulate against host state, and every fix you add for one machine breaks two others. The self-contained-bundle approach takes that whole class of failure off the table. **Files:** `installers/windows/build.py` stages everything under `installers/windows/build/staging/`, `installers/windows/bambuddy.iss` is the Inno Setup 6 script, `installers/windows/service/install-service.bat` + `uninstall-service.bat` wrap NSSM. `build.py` hard-fails on non-Windows hosts; cross-build under Wine is an unsupported escape hatch behind `--allow-non-windows`. **CI:** `.github/workflows/windows-installer.yml` runs on tag push (`v*`) and manual dispatch, uses `windows-latest`, downloads Inno Setup via Chocolatey, runs `build.py` + ISCC, uploads the `.exe` as both a workflow artifact and a release asset. **Scope clarification:** this commit lands the build infrastructure, not a verified-working installer. The first real Windows-box smoke test happens after merge by triggering the workflow manually and installing the artifact on a target box; known unknowns are pip-installing `opencv-python-headless` / `curl_cffi` / `asyncpg` / `cryptography` / `bcrypt` against embedded Python (the `_pth` file edits in `build.py` cover the common gotchas but real-runtime imports are where surprises surface), ffmpeg path lookup from a LocalSystem service, and NSSM `AppEnvironmentExtra` line-continuation in cmd.exe. **Signing:** v1 ships unsigned — Windows SmartScreen will warn "Windows protected your PC" on first run, click-through works. SignPath OSS application submitted 2026-06-10 to wire free EV signing into CI once approved (typical 1–3 week approval window). **What's explicitly NOT in v1:** Spoolman bundling (Bambuddy's internal-inventory mode is the v1 default on Windows; users who want Spoolman install it separately), in-place upgrade (uninstall + install cycle works, but in-place upgrade-on-top needs end-to-end verification before we promise it), port-conflict pre-check (deferred to v1.1 — port collisions surface at first service start and the user reads the NSSM stderr log under `C:\ProgramData\Bambuddy\logs\service-stderr.log`). See `installers/windows/README.md` for the full build pipeline. - **VP wire-payload dump escape hatch for shape-of-payload triage (#1622 investigation)** — When a virtual printer in non-proxy mode is misbehaving for the slicer-facing surface (AMS slot fields rendering empty, filament dropdown unselectable, K-profile not visible), the existing logs prove the bridge is bound and pushing at 1Hz but don't show what's actually in the wire payload. Without that, "cache is missing fields" is indistinguishable from "the slicer-facing copy is stripping them." Set `BAMBUDDY_VP_DUMP_WIRE=1` and Bambuddy writes the bridge's cached push_status (`/vp_wire/_in.json`) and the periodic 1Hz copy that gets sent to the slicer (`/vp_wire/_out.json`) to disk, overwritten on each tick. Diffing the two answers the bisect question; diffing a misbehaving VP's `_out.json` against a known-good VP's `_out.json` (e.g. P1S vs H2D in the #1622 case) answers the model-shape question. Off by default, no overhead when disabled (single env-var read per tick); env var re-read on every call so toggling without restart works; failures swallowed at debug so a broken dump can never break the 1Hz loop. Implementation lives in `backend/app/services/virtual_printer/_debug.py` with call sites in `mqtt_server.py::_send_status_report` (cached branch only — synthetic fallback is uninteresting for this triage) and `mqtt_bridge.py::_on_printer_raw` (immediately after the merge that produces `_latest_print_state`). 21 unit tests in `test_vp_wire_dump.py` pin: disabled-by-default, atomic tmp+rename writes (no half-written .json visible to a reader), sanitized vp_name (path-separator stripped, empty name falls back to `vp`, .. inside a single filename component is harmless because slashes are collapsed before path construction), per-call env check, dict + bytes + str payload acceptance, swallow-on-OSError. Not gated on debug-logging because the bridge's verbose path is already noisy; this dump is small (one file per direction per VP) and only present when the operator opts in. Diagnostic-only — does not change the bridge data path. - **VP slicer↔printer command-flow trace (#1622 round 2)** — The snapshot dump above answers "is the cached push shape correct?", but the round-1 captures from #1622 ruled that out: P1S AMS payload reaches the slicer byte-identical to what the printer sent, sticky-key preservation works, the visible slot data is intact. The remaining symptom (picking a generic filament in archive mode "unloads" the slot) lives on the command path, not in the periodic push — and the snapshot dump doesn't capture command traffic. Same env flag (`BAMBUDDY_VP_DUMP_WIRE=1`) now also appends every slicer-originated publish on `device//request` AND every printer-originated response the bridge fans out to the slicer (extrusion_cali_get, ams_filament_setting acks, xcam, system, etc.) to `/vp_wire/_cmd.jsonl`, one JSON line per event with UTC iso timestamp, direction (`slicer_to_bridge` / `printer_to_slicer`), MQTT topic, a `.` grep handle, and the parsed payload. Excludes the cached-as-base 1Hz push (already covered by the snapshot dump) and `pushall`/`get_version` (handled locally, never forwarded). Printer-side captures happen AFTER serial rewrite so the dump matches what the slicer actually saw on the wire. New `append_event` helper in `_debug.py` mirrors the same swallow-on-OSError + sanitized-vp_name + per-call env-check posture as `dump_wire`; bytes payloads are utf-8 decoded then json-parsed with the same `\x00`-tolerance fix from #927 so OrcaSlicer's C-string-null publishes parse cleanly; un-parseable bytes fall back to `{"raw": "..."}` so every line stays valid JSON. Eight additional unit tests in `test_vp_wire_dump.py` pin: disabled-by-default, bytes parsing, trailing-null tolerance, unparseable-fallback, vp_name sanitization, iso timestamp shape, append-multiple-lines, swallow-on-OSError. Diagnostic-only — does not change the publish or fan-out data path. - **VP bridge-synthesised reply trace (#1622 round 3)** — The round-2 cmd.jsonl from shaddowlink's P1S vs H2D capture proves the actual failure mode: on P1S in archive mode the slicer issues `extrusion_cali_set` (push K/n directly) and the printer responds `fail`, on H2D and on the P1S second round the slicer takes the `extrusion_cali_sel` flow (select by `filament_id` / `cali_idx`) and the printer responds `success`. Both flows traverse the bridge cleanly — `ams_filament_setting` round-trips with `result=success` and the cached push_status carries `tray_info_idx=GFA11`, `tray_type=PLA-AERO`, K/n, and `cali_idx=-1` intact. So the bridge is innocent on every layer the dump can see, and the open question becomes: what makes the slicer pick `_set` vs `_sel`? Likely candidates are the `info.get_version` answer Bambuddy synthesises (slicer fingerprints on `sw_ver` / `hw_ver` / `module` to decide its command flow) or the first cached `pushall` response the slicer reads to bootstrap its UI. Round 2 captured neither — the JSONL had `slicer_to_bridge` and `printer_to_slicer` directions but no `bridge_to_slicer` direction for the bridge's own synthesised replies. Same env flag (`BAMBUDDY_VP_DUMP_WIRE=1`) now also appends every bridge-synthesised reply (info.get_version answer, project_file ack, on-demand pushall response) to `/vp_wire/_cmd.jsonl` under direction `bridge_to_slicer`. Capture lives in `mqtt_server.py::_publish_to_report` — the single chokepoint every synthesised reply already passes through — gated on a new `log_event: bool = True` parameter; the 1Hz periodic-push path threads `log_event=False` so the JSONL isn't flooded with ~60 lines/min per VP (snapshot dump already covers cache shape). The on-demand pushall response from `_send_status_report` IS logged because that's the bootstrap-fingerprint reply the slicer reads on first connect. Two additional unit tests in `test_vp_mqtt_bridge.py::TestWireFormat` pin the event-on-default and skip-when-`log_event=False` posture; `test_vp_wire_dump.py` already covers the underlying `append_event` shape and the new direction is documented in `_debug.py`'s docstring. Diagnostic-only — does not change the publish data path; the new param defaults preserve every existing call site's behaviour. - **Batch grouping for queued items** — multi-plate prints from one source 3MF now auto-group into a single collapsible row with aggregate stats, and a new "Group as batch…" action turns any 2+ selected items into a manual batch. Per-batch collapse state persists across reloads. Manual batches can be disbanded via the Ungroup action on the batch parent. - **History batch grouping** — siblings of the same batch collapse into one history row with status-rollup chips (e.g. 3 ✓ / 1 ✗) and the latest activity timestamp. - **History thumbnail hover preview** — hover any small history thumbnail and a 192×192 preview pops out next to it. ### Changed - **Queue page restructured around three tabs** — Queue, History, and Timeline now live as separate tabs at the top. History no longer competes with the active queue for screen space. - **Active queue layout toggle** — pick between a flat list (current default) and a per-printer view where each printer becomes a section card with aggregate item count, total time, and total filament weight in its header. - **Multi-drag reorder** — selecting N items and dragging any one of them moves the whole selection as a contiguous block; the drag ghost shows a "+N" badge. - **History rows redesigned** — each row now carries a filament color swatch + weight + type, the user who started the print, and the failure reason inline on failed/skipped rows. Rows lay out in a responsive 1/2/3 column grid so a long history uses available horizontal space. - **Timeline tab rebuilt as a Gantt swimlane** — one horizontal row per printer (plus per target_model and unassigned), jobs rendered as bars positioned by start time and sized by duration. Live NOW marker, 24-hour rolling window with 12-hour step controls. Only committed schedules are shown — staged items, waiting items, and ASAP jobs on idle printers are hidden so the timeline reads as a real forecast. ### Fixed - **Virtual Printer queue mode: multi-plate "Send All" now enqueues one queue item per plate** — BambuStudio / OrcaSlicer's "Send All" packs every plate of the project into a SINGLE 3MF and uploads it with one FTP STOR — `slice_info.config` inside the file carries N `` blocks (one per plate), each with its own `` and its own `Metadata/plate_N.gcode` payload. Previously the VP queue path only ever extracted the FIRST plate's index via `_extract_plate_id` and created exactly ONE PrintQueueItem with that single `plate_id`; plates 2..N silently dropped on the floor. Indistinguishable from the user's perspective from "Send" of a single plate — except they expected 3 items in the queue and got 1, with no log line to explain why. **Confirmed against the wire** on the live H2D-1 Proxy VP: `Cube.gcode.3mf` carrying three `` blocks (indices 1, 2, 3) + three per-plate gcode payloads in the same zip, identical filename whether "Send" or "Send All" was clicked — the only signal of intent is the count of `` blocks inside the file. **Fix:** replaced `_extract_plate_id` (returning `int | None`) with `_extract_plate_ids` (returning `list[int]`). The list contains every `` block's `index` metadata, in order; falls back to `[1]` for files missing `slice_info.config` or with no parseable plates so the single-plate path is preserved. `_add_to_print_queue` now loops over the list — each iteration calls `extract_filament_requirements(file_path, plate_id)` per-plate (the plate-aware path was already there from the #1697 work) and creates a PrintQueueItem with that plate's filament types / overrides, plate-specific position = `MAX(position) + iteration`. Single-plate "Send" hits the loop once → exactly today's behaviour (one queue item, plate_id from the slicer, same archive). Multi-plate "Send All" of a 3-plate file → 3 queue items, plate_id 1/2/3, consecutive positions, all pointing at the same backing archive (one upload = one archive). **What stays the same:** the single archive row per upload (the archive backs the queue items via `archive_id`); the `auto_dispatch=False` / `manual_start=true` posture inherited from the VP config (so multi-plate items still require manual start); the `queue_force_color_match` per-VP toggle (now applies per-plate). **What this also fixed downstream:** the `required_filament_types` / `filament_overrides` JSON on each queue item now reflects THAT plate's filaments, not the file's first plate — so the scheduler's per-printer "Any X" matching dispatches each plate onto a printer with the right colours loaded for THAT plate, not for plate 1's filament set. **Tests:** 1 new regression case in `test_virtual_printer.py::TestVirtualPrinterInstance::test_add_to_print_queue_multi_plate_send_all_enqueues_one_per_plate` — builds a 3-plate 3MF (writes the per-plate `` blocks into `slice_info.config` and the per-plate gcode payloads), runs `_add_to_print_queue`, asserts 3 PrintQueueItems with `plate_id == [1, 2, 3]`, `position == [1, 2, 3]`, shared `archive_id`, all `manual_start=True`. 126 existing single-plate VP tests stay green (loop runs once when input has one plate). Full backend suite 5962/5962 green; ruff clean; frontend untouched. **Live-verified** on the H2D-1 Proxy VP — a Send All of the 3-plate Cube project now produces 3 queue items + 1 archive instead of 1 queue item + 1 archive. - **Archive delete now removes related queue items instead of leaving "cancelled" rows behind** — Previously the soft-delete path (the default — what the trash-can button does) called `_cancel_pending_queue_items`, which only flipped queue rows with `status='pending'` to `status='cancelled'` while leaving every other status alone AND leaving every row in the DB. The Send All multi-plate work above made this much more visible: deleting an archive backed by N queue items now had to clean up N rows, and what users saw instead was N "cancelled" rows lingering in the queue history. **Fix (backend):** replaced `_cancel_pending_queue_items` with `_delete_related_queue_items(db, archive_id) -> int` that DELETEs every queue row where `archive_id = X` regardless of status. Behavior now matches what the hard-delete path already did via the `ON DELETE CASCADE` FK on `print_queue.archive_id` — both paths produce the same end state. Print history lives in `PrintLogEntry` (FK `ON DELETE SET NULL`) and is untouched, so stats / Quick Stats / accuracy bands are preserved across both delete paths. **New guard:** the route at `archives.py::delete_archive` now 409s when any related queue item is currently in `status='printing'` — both soft and hard delete are gated by the same precondition, because deleting the archive while a print is live would strip the dispatcher's metadata trail (filament / plate / ams_mapping) out from under the running print. The 409 surfaces a clear "Stop the print first, then retry" message. **Pre-flight count for the UI:** new endpoint `GET /archives/{id}/delete-impact` returns `{related_queue_items: N, currently_printing: M}` — cheap, single endpoint, not folded into the archive list response so the much larger list endpoint isn't forced to run the same query per row. Frontend ArchivesPage delete-confirm modal queries this when the modal opens (`useQuery({queryKey: ['archive', id, 'delete-impact'], enabled: showDeleteConfirm})`) and renders: an amber warning "**N queue item(s) linked to this archive will also be removed.**" when total > 0 AND printing = 0, OR a red warning "**Cannot delete — M queue item(s) are currently printing. Stop the print first, then retry.**" when printing > 0 (with the confirm button disabled in that case so the user can't bonk the 409 on submit). **ConfirmModal extension:** added optional `confirmDisabled?: boolean` prop. Existing `isLoading` was the only disable knob; this adds an external-precondition path that disables the confirm without the loading spinner. **Tests:** rewrote `test_print_queue_api.py::test_soft_delete_archive_cancels_pending_queue_items` → `test_soft_delete_archive_deletes_all_related_queue_items` to pin the new contract (both pending AND completed rows are gone post-soft-delete). 2 new integration cases in `test_archives_api.py`: `test_delete_archive_blocked_when_related_queue_item_printing` (both soft and hard paths return 409 with "printing" in detail message) + `test_archive_delete_impact_reports_counts` (3 mixed-status related rows + 1 unrelated row → endpoint reports `related_queue_items=3, currently_printing=1`, unrelated row doesn't bleed in). **i18n:** 2 new keys (`archives.modal.deleteQueueItemsWarning`, `archives.modal.deleteBlockedByPrinting`) translated across all 11 locales per `feedback_translate_dont_fallback` — no English fallbacks. **Verification:** full backend suite 5964/5964 green with `-n 30`; ruff clean; ESLint clean; `npm run build` clean; vitest 2118/2118 green; i18n parity 5109 × 11 locales green. No DB migration — the CASCADE FK was already in place; only the helper's semantics changed. - **Print Log table: multi-color filament rows render one swatch per color instead of a single barely-visible gray dot (#1731 part 1, reported by @IndividualGhost1905)** — The per-archive Print Log table cell at `frontend/src/pages/ArchivesPage.tsx:3882` rendered the `filament_color` column as ONE swatch with `style={{ backgroundColor: entry.filament_color.startsWith('#') ? entry.filament_color : undefined }}`. For multi-color prints, the backend writes `filament_color` as a comma-joined string (e.g. `"#FFFFFF,#000000,#FF0000"` — three filaments used in the print), which trivially passes the `.startsWith('#')` check but is not a valid CSS color. The browser silently dropped the `backgroundColor` declaration, leaving the swatch as only its black/20% border on the app's dark theme — visually a tiny grey dot, near-invisible against the row background, which the reporter's screenshots showed as "PLA" text in the cell with no apparent swatch at all. The DB column was correct (the reporter confirmed both colors were recorded for the old example); the render dropped them. The Archive Card view at `:1072-1083` and `:2114-2125` already split on comma and rendered one swatch per color — only the Print Log table cell had been missed when multi-color support was added across the rest of the page. **Fix:** the Print Log table cell now mirrors the card-view pattern — wraps the swatches in a `flex` container, splits `entry.filament_color` on `,`, trims each value, and renders one `w-3 h-3 rounded-full` per color with `backgroundColor: trimmed.startsWith('#') ? trimmed : undefined` and a `title={trimmed}` for hover-tooltip parity. Single-color prints render exactly one swatch (the trivial case — no behaviour change). Empty / non-hex slot values gracefully fall through to no `backgroundColor` rather than poisoning the CSS for adjacent slots. The filament-type text (`{entry.filament_type || '—'}`) keeps its existing position to the right of the swatches. **What this does NOT fix:** the reporter also flagged that new multi-color prints don't appear in the filament usage history. That's a separate code path (`backend/app/services/usage_tracker.py::_track_from_3mf` and the slot-to-tray mapping chain at `usage_tracker.py:899-901`), where the diagnostic needs the archive's captured `ams_mapping`, the `mapping` field from MQTT push_status at print start, and the `[UsageTracker] PRINT START` / `PRINT COMPLETE` log lines — none of which are in the reporter's first bundle. Tracking under #1731 part 2, blocked on a support bundle from the affected install. **Tests:** existing `ArchivesPage.test.tsx` (23 cases) green; ESLint clean; `npm run build` clean; i18n parity 5107 leaves × 11 locales green (no new keys). Frontend-only change. - **Finish-photo force-on removed; user's explicit timelapse=off in the slicer send dialog is now respected (#1721, reported by @agrisci)** — On H2D 01.x firmware, `capture_finish_photo` (default-on global setting) was forcing every print's `timelapse` MQTT field to `enable` regardless of whether the user had unchecked the Timelapse box in OrcaSlicer's send dialog. That bit flips the printer's runtime `timelapse_record_flag`, which un-gates the slicer-baked `M1002 judge_flag timelapse_record_flag` / `M622 J1` / `G1 X-48.2 F3000` / `M971 S11 C11 O0` wipe blocks emitted by **Smooth**-mode timelapse profiles — so the toolhead parked off the part and snapped a frame every single layer, on prints the user explicitly opted out of recording. The reporter's gcode export confirmed the macro block was baked in (28 occurrences across the file) and the printer's MQTT log showed `Sending print command: {"print": { … "timelapse": true, … }}` even though the slicer-side checkbox was unchecked. Live-stop confirmed: turning the global `capture_finish_photo` setting off in Bambuddy made the per-layer parking stop immediately. **Root cause:** the #1397 "finish photo from timelapse" feature used "force the printer into timelapse-recording mode at dispatch" as the side-channel to get a well-framed end-of-print shot (toolhead parked, before bed drop, extracted from the recorded video's last frame). That mechanism conflated two semantically different things — recording a timelapse video vs. snapping a finish photo — and the per-layer side effects of the recording mode were decided at slice time by the user's `timelapse_type` profile setting, which Bambuddy has no visibility into post-slice. Traditional-mode gcode has no per-layer wipe block (no parking, no defects) — so the bug was invisible to anyone whose slicer profile defaults to Traditional. Smooth-mode gcode (the reporter's case) bakes the wipe block and gates it on `timelapse_record_flag`, so flipping the runtime flag fired the macro every layer. **Fix:** replaced the force-on mechanism entirely with a clean MQTT-state-driven trigger. `bambu_mqtt.py::_handle_push_status` now fires a new `on_finish_photo_moment` callback when `stg_cur` transitions INTO **22** ("Filament unloading") while `_was_running == True` AND the end-of-print gate matches (`progress >= 99` OR `layer_num >= total_layers` OR `remaining_time <= 0`) — that's the same framing window #1397 was after (toolhead parked, bed not yet dropped, AMS pulling filament back) but reached via a clean state signal instead of by exploiting the per-layer macros. The end-of-print gate is what disambiguates from mid-print filament swaps in multi-color prints, which ALSO transit through stage 22 (M620 unload → 22, M621 load → 24) but always at progress < 99 / layer < total / remaining > 0. A FINISH-state fallback in the same handler fires the same callback at the existing FINISH-state transition if stage 22 never arrived — covers cancel-mid-print (state goes RUNNING → IDLE / FAILED without 22), external-spool-only prints where some firmwares skip the unload phase, HMS halts before unload, and any firmware variant we don't see stage 22 on. Net behavior: every print that gets a finish photo today still gets one; the lucky majority get the better-framed pre-bed-drop shot too. `main.py::on_finish_photo_moment` is a new top-level handler that pre-captures one camera frame at the trigger edge — external camera (snapshot URL → MJPEG fallback), buffered live RTSP frame from `_active_streams` / `_active_chamber_streams`, or a fresh RTSP grab via `capture_camera_frame_bytes` — and caches the JPEG bytes in a module-level `_stage22_finish_frames: dict[int, bytes]` keyed by printer_id. `_background_finish_photo` (inside `on_print_complete`) consumes the cached bytes via `_stage22_finish_frames.pop(printer_id, None)` before falling through to its existing live-grab chain, so the saved photo has the better framing without the existing complex archive-resolution / fallback / notification wiring needing to move. When a timelapse IS actively recording (user explicitly opted in this time), the pre-capture is skipped — `_capture_finish_photo_from_timelapse` still extracts the last frame from the recorded video, which is still the highest-quality option and now has no force-on side effects because the user actually wanted the video. **What was removed:** `resolve_effective_timelapse` in `background_dispatch.py` (the shared force-on resolver), `BackgroundDispatchService._resolve_effective_timelapse` wrapper, both call sites in `background_dispatch.py` (`_run_reprint_archive` + library-file print path), the `resolve_effective_timelapse` call in `print_scheduler.py::_dispatch_item`, the `archive.bambuddy_forced_timelapse` write in the resolver, the `if archive.bambuddy_forced_timelapse: await _cleanup_forced_timelapse(...)` branch in `_background_finish_photo`, and the entire `_cleanup_forced_timelapse` function (~75 lines including the FTP-DELE walk across `/timelapse` / `/timelapse/video` / `/record` / `/recording`). All call sites now read `bool(item.timelapse)` / `bool(job.options.get("timelapse", False))` directly — the literal user choice flows straight through to `start_print(timelapse=…)`. The `archive.bambuddy_forced_timelapse` DB column stays defined (default `False`) for back-compat with existing rows that may have it set to `True` from before — no consumer reads it anymore, and dropping a column on the user-data table risks breaking restore-from-backup flows we don't need to break. **New callback wiring:** added `on_finish_photo_moment` parameter to `BambuMQTT.__init__`, new `_finish_photo_captured` one-shot flag (reset on each new print at the same site as `_completion_triggered`), new `PrinterManager._on_finish_photo_moment` field + `set_finish_photo_moment_callback` setter, new `on_finish_photo_moment` inner wrapper in `_setup_callbacks`, threaded through to the `BambuMQTTClient` constructor call. `main.py::on_print_start` clears any leftover `_stage22_finish_frames` entry from a prior print so a never-consumed cache (e.g. capture succeeded but on_print_complete bailed before reaching it) can't bleed into the new print's photo. **Tests removed:** `test_cleanup_forced_timelapse.py` (~290 lines, 7 test cases pinning the FTP-DELE walk and `bambuddy_forced_timelapse` flag handling), `test_scheduler_force_timelapse_wiring.py` (the source-pattern check that pinned `print_scheduler.py` imports `resolve_effective_timelapse`), `test_dispatch_force_timelapse.py` (5 test cases pinning the `_resolve_effective_timelapse` wrapper's interaction with `capture_finish_photo` + archive flag). The behaviour these tests verified is intentionally gone. **Tests updated:** `test_background_dispatch_watchdog.py` dropped two `patch.object(BackgroundDispatchService, "_resolve_effective_timelapse", ...)` blocks that stubbed the now-removed method; `test_background_dispatch.py::test_dispatch_options_pass_through_pattern` comment updated to explain why `timelapse` stays excluded from the bare-pattern needle check (the wrap in `bool(...)` is intentional to coerce non-bool option payloads, not a force-on remnant). **Verification:** ruff clean; full backend suite 5961/5961 green with `-n 30`; ESLint clean; `npm run build` clean; vitest 2118/2118 green; i18n parity 5107 leaves × 11 locales green (no new keys). No migration. **What this does NOT change:** users who explicitly enable the Timelapse checkbox in the slicer send dialog still get the timelapse video AND the timelapse-extracted finish photo (highest-quality framing, no per-layer parking because that was never the issue — it's the user's intentional choice). Users who explicitly disable the Timelapse checkbox now get no per-layer parking AND still get a finish photo (pre-captured at the stage-22 edge for the same pre-bed-drop framing). - **Configure AMS Slot: filament profiles for other printer models now filtered out (#1623, reported by @shaddowlink)** — Three independent gaps in the same picker, each surfaced by a different round of reporter screenshots. **(1) Local "Custom" imported profiles** were unconditionally listed regardless of the slot's printer; a user with PETG / PLA profiles imported from OrcaSlicer / BambuStudio for A1 mini, H2D, and P1S saw all three lined up when configuring an AMS slot on any one of those printers. **(2) Cloud presets using the `@Bambu Lab ` suffix form** (user-renamed Bambu Cloud presets and most Orca Cloud profiles) slipped through the existing filter, which only matched the `@BBL ` form Bambu's system presets use. **(3) Cloud presets with the printer model in the BODY of the name** (the literal failure shape the reporter screenshotted on H2D: `"X1C eSUN PETG-Basic Filament"` with no `@` suffix at all) — the existing extractor returned null for these and the filter no-op'd. **Fix:** `ConfigureAmsSlotModal.tsx` now (a) queries the backend's Bambu printer-model registry (`/slicer/printer-models`, same fetch SliceModal uses), (b) for local presets — reverse-looks-up the slot's short model code to a long printer-preset fragment, pairs it with the slot's nozzle diameter to synthesise the full slicer preset name ("Bambu Lab P1S 0.4 nozzle"), and passes that into `presetCompatibility(...)` from `utils/slicerPrinterMatch.ts` against each local preset's parsed `compatible_printers` JSON; (c) for cloud / Orca Cloud presets — `extractPresetModel(name, registry)` is now multi-strategy: first the `@BBL ` form (existing), then the `@Bambu Lab ` form with case-insensitive reverse-lookup against the registry (so "A1 mini" vs "A1 Mini" capitalisation drift doesn't hide A1 Mini profiles, preserving the #1649 alias-aware match), then a body-text scan against every known model token (long-name fragments and short codes from the registry, long-first sort so "A1 Mini" / "X1 Carbon" / "H2D Pro" aren't eaten by their shorter siblings, word-boundary regex so "PA1" doesn't match "A1" and "X1Box" doesn't match "X1"). Presets where no strategy resolves still pass through — free-form names with no recognisable model token stay visible (can't filter what we can't classify). **Fail-open posture preserved:** `match` and `unknown` verdicts keep showing for local presets (back-compat for hand-edited imports without `compatible_printers`); the currently-configured preset (`slotInfo.savedPresetId`) bypasses the filter so the active selection always remains visible; built-in filaments stay unfiltered (generic fallback); when the registry hasn't loaded yet OR `printerModel` is empty, every filter no-ops. **No backend / schema / i18n changes.** Frontend ESLint clean; `npm run build` clean; vitest `ConfigureAmsSlotModal` 24/24 green. - **Virtual Printer: empty AMS slots forwarded as phantom loaded filaments to BambuStudio Sync (#1726, reported with full code-level analysis by @needo37)** — On any VP bound to a target printer (Proxy mode, or Queue mode with a specific target), the slicer-facing AMS state was the printer's raw push_status — the empty-slot cleanup that `bambu_mqtt.py::_handle_ams_data` applies to Bambuddy's own internal state was NEVER run on the bridge cache. Concrete case: real printer has 3 filaments loaded (AMS-A slots 2/3/4), AMS-A slot 1 and all of AMS-B empty; Bambuddy's AMS card renders the empty slots correctly as Empty (control — internal state path is fine), but BambuStudio after Sync paints 7 populated/green-checked filament slots — the 3 real ones plus 4 phantoms whose color/material is stale RFID/calibration data from before those slots went empty. The diagnostic signature is the mismatch between the AMS card (correct) and the slicer view (wrong) for the same payload. Archive mode and Queue-by-model are NOT affected — no target printer → no bridge → the slicer gets the synthetic stub at `mqtt_server.py:927` with no real AMS data. **Root cause:** two code paths consume the same printer AMS payload. Internal (`bambu_mqtt.py::_handle_ams_data` lines 1802-1858) parses `tray_exist_bits`, promotes empty slots to `state=9`, and wipes the stale `tray_type`/`tray_color`/`tray_info_idx`/`tag_uid`/`tray_uuid`/`remain` fields. VP bridge (`mqtt_bridge.py::_on_printer_raw` lines 551-656) deep-merges AMS structurally via `_merge_ams_dict` and copies `tray_exist_bits` through as an opaque top-level scalar — but never applies the bit→clear-empty-slot logic. The cached state ships to the slicer untouched. **Fix:** factored the bit-clear logic out of `_handle_ams_data` into a shared module-level helper `bambu_mqtt.py::apply_tray_exist_bits(units, tray_exist_bits_str, *, power_on_flag, log_label)` and call it from both paths. The internal call site is replaced with a single helper invocation; the bridge calls it on the merged AMS dict after `_merge_ams_dict` runs, before the 1 Hz cached-as-base push picks the cache up. Shared shutdown guard preserved on both sides: all-zero bits + `power_on_flag=False` is the printer-off pattern (#765) and skips cleanup — a non-zero bits + power-off combo is valid idle-printer state (#1365 — X1C between prints) and still applies. AMS-HT units (`id >= 128`) skipped on both sides (separate addressing scheme). **Tests:** new `TestApplyTrayExistBitsHelper` class in `test_bambu_mqtt.py` (10 cases pinning the helper contract directly — missing/unparseable bits → no-op, shutdown guard, nonzero+power-off X1C case, int-9 state, AMS-HT skip, string id handling, multi-AMS global bit math, state-promote-even-without-stale-data). 3 new bridge regression tests in `test_vp_mqtt_bridge.py::TestPushStatusCache`: `test_tray_exist_bits_clears_empty_slots_in_slicer_cache` reproduces the #1726 wire shape (slot 0 carries stale `tray_type`/`tray_color`/`tray_info_idx`/`tag_uid`/`tray_uuid`/`remain` + `tray_exist_bits="e"` → slot 0 must clear, slots 1-3 preserved), `test_tray_exist_bits_shutdown_guard_preserves_cache` pins the printer-off path won't propagate phantom empties on every reconnect, `test_tray_exist_bits_skips_ams_ht_units` pins the HT addressing skip. Existing internal-state tests for the bit-clear logic (`test_tray_exist_bits_clears_empty_slots`, `test_tray_exist_bits_promotes_empty_slot_to_state_9`, `test_tray_exist_bits_does_not_change_state_on_loaded_slots`, …) continue to pass against the refactored internal path — same contract, same behavior, different implementation seam. One pre-existing bridge fixture (`test_partial_ams_unit_update_preserves_other_units`) had an inconsistent `tray_exist_bits="3"` for two AMS units (bit 0 set, bit 4 unset, but both unit 0 and unit 1 had slot 0 populated as loaded). The fix exposed the inconsistency — corrected to `"11"` (bits 0 + 4) to match what the real printer would send. Full backend suite 5955/5955 green; ruff clean; i18n parity 5107 leaves × 11 locales green (no new keys). Frontend untouched. **Verification on a live system (per @needo37's analysis):** set `BAMBUDDY_VP_DUMP_WIRE=1`, restart, Sync the slicer, inspect `/vp_wire/_out.json`. For any tray whose bit in `tray_exist_bits` is 0, `tray_type`/`tray_color` should now be empty. - **Windows: `/api/local-backup/status` 500 on `ZoneInfoNotFoundError: 'No time zone found with key UTC'` (from a user's log on the Windows installer)** — Reported via a Windows traceback against the new local-backup status endpoint. The stdlib `zoneinfo` module reads the system IANA tz database on Linux/macOS, but Windows has none — and the embedded Python in our Windows installer doesn't carry the `tzdata` PyPI package either, so even `ZoneInfo("UTC")` raises `ZoneInfoNotFoundError`. `_local_zone()` in `services/local_backup.py` only caught that exception for the `TZ`-env branch; the empty-`TZ` fallback and the unrecognised-`TZ` fallback both unconditionally called `ZoneInfo("UTC")` and re-raised, bubbling out of the FastAPI handler as a 500. **Fix (two parts):** (1) `_local_zone()` is now resilient — return type widened from `ZoneInfo` to `tzinfo`, the `UTC` fallback is wrapped in its own try, and the last-resort fallback returns the stdlib `datetime.timezone.utc` (which needs no IANA DB and satisfies every `astimezone` / `str()` call site downstream — `str(timezone.utc) == "UTC"` matches the previous response shape). Restores function on existing Windows installs without re-bundling. (2) `requirements.txt` now pins `tzdata>=2024.1; sys_platform == "win32"` so the next Windows installer build ships the IANA DB and any non-UTC `TZ` value (e.g. `Europe/Berlin`) resolves correctly — the stdlib fallback can only ever give UTC. Linux/macOS unaffected: the platform marker keeps them on the system tz DB they already have. **Tests:** new `test_zoneinfo_completely_unavailable_falls_back_to_stdlib_utc` in `test_local_backup.py` monkeypatches `ZoneInfo` to always raise `ZoneInfoNotFoundError` and pins that `_local_zone()` returns `datetime.timezone.utc` rather than propagating. 31/31 local_backup tests green; ruff clean. - **Print-modal "off" toggles for `flow_cali` and `nozzle_offset_cali` now actually suppress the calibration stage (live-tested on H2D 01.x)** — The Re-print / Schedule modal toggles for Flow Calibration and Nozzle Offset Calibration accepted the user's "off" choice and flowed it correctly through to the `project_file` MQTT publish — Bambuddy sent `extrude_cali_flag: 2` and `nozzle_offset_cali: 2` per our reading of "1 = run, 2 = skip" inherited from the #1478 / #1682 work. Live test on an H2D running firmware 01.x: with both toggles off in Bambuddy's modal, the printer's `stg` queue (the pre-print stage list firmware publishes via push_status) still included stage **8** ("Calibrating dynamic flow") and stage **39** ("Nozzle offset calibration") — and physically ran them at print start. The `2` value did NOT suppress the stage despite our earlier "skip and reuse stored PA" reading. **Root cause:** the encoding for the "off" wire value is `0`, not `2`. The `2` value appears to mean "skip the explicit calibration pass but still apply / verify the stored PA value via the calibration stage" — close to a no-op in terms of K-factor but the printer still queues the stage and runs the per-print physical sequence. `0` is what actually drops the stage from the `stg` queue. A real BambuStudio Send-dialog capture on the same firmware (proxy-mode VP echo) also showed `0` for both fields when calibrations are unchecked, contradicting the #1478 commit message which read `0` as "never sent by BambuStudio." **Fix:** `bambu_mqtt.py::start_print` — `extrude_cali_flag` is now `1 if flow_cali else 0` (was `2`), and `nozzle_offset_cali` is `1 if (nozzle_offset_cali and is_dual_nozzle) else 0` (was `2`). The dual-nozzle gate stays — single-nozzle prints continue to force-skip the nozzle-offset calibration their head doesn't support (#1682). `1` (run) is unchanged on both fields. **Verification:** live re-test on the same H2D with both toggles still off — `stg: [29, 13, 4, 14, 3]` (cooling, homing, filament change, nozzle cleaning, vibration comp). Stages 8 and 39 dropped out cleanly. **What's NOT fixed:** `vibration_cali` is a JSON `false` bool in both Bambuddy's and BambuStudio's wire format, and the H2D firmware queues stage **3** ("Vibration compensation") regardless of the bool value — this is firmware-side and not solvable at our dispatch layer with the current field. Captured as a follow-up to investigate whether a parallel `vibration_cali_flag` integer field exists. **Tests:** `test_bambu_mqtt.py` — `test_p2s_uses_boolean_format` flipped `extrude_cali_flag == 2` → `== 0`; `test_nozzle_offset_cali_default_is_skip`, `test_nozzle_offset_cali_ignored_on_single_nozzle`, `test_nozzle_offset_cali_false_on_dual_nozzle` flipped `== 2` → `== 0`; docstrings updated to reflect the #1721 finding. The `1 if user_wants` branch in both tests for the "on" case is unchanged. 281/281 bambu_mqtt tests green; full backend suite 5941/5941 green with `-n 30`; ruff clean; frontend untouched (rebuild + i18n parity confirmed clean per `feedback_run_all_ci_checks`). - **Support-bundle log noise: VP bridge nudge + SD-card cleanup (#1721 adjacent, observed on reporter's A1)** — Two warnings polluting every A1 support bundle on a healthy print. Neither was the cause of #1721's timelapse complaint — both are adjacent noise. **(1) `request_status_update: not connected`** — `mqtt_bridge.py::_resolve_client` calls `_request_version` + `request_status_update` immediately after attaching a raw-message handler so the bridge cache populates without waiting for the next periodic pushall. The bind frequently races the real printer's MQTT TLS handshake — a slicer-side reconnect re-resolves the client before the underlying session has reconnected, especially on A1 firmware which reconnects more aggressively than X1/H2/P. `request_status_update` logs `[serial] request_status_update: not connected` at WARNING on the not-connected return path. The nudge is a best-effort optimisation; the fall-through (next periodic pushall) populates the cache anyway, so the WARNING fires on routine, expected, recoverable state. **Fix:** gate both nudges on `current.state.connected` at the bind site. When the client comes up, the next `_resolve_client` tick re-enters this branch on identity change OR the periodic pushall in `bambu_mqtt.py` fills the cache — same end state, no benign WARNING. The WARNING in `bambu_mqtt.py:3224` is unchanged: it's still a real signal for the other callers (`/printers/{id}/refresh-status` user API, bug-reporter helper) where "you asked for a refresh on a dead client" is genuinely worth logging. New `test_post_bind_nudge_skipped_when_target_not_connected` in `test_vp_mqtt_bridge.py::TestBridgeLifecycle` pins the contract. **(2) `SD card cleanup failed after 3 attempts ... (file may linger on SD card)`** — The post-finish helper in `main.py` deletes the uploaded file from the printer's SD card to prevent the ghost-print-on-power-cycle behaviour (#374, #1542). It tries up to three candidate paths (`derive_remote_filename(archive.filename)`, then `{subtask_name}.3mf`, then `{subtask_name}.gcode`), each up to 3 times with 2 s backoff, then logs WARNING if all fail. `delete_file_async` returned `bool` — `True` for success, `False` for ANYTHING else (FTP 550 file-not-found, network error, auth fail, transient FTP error). The A1 firmware (and most other Bambu firmwares post-print) cleans the SD-card upload itself before our cleanup runs, every candidate FTP-DELE returns 550, all three retries × three candidates × 2 s sleeps fire, then WARNING. That WARNING shouldn't exist on a healthy print where the printer self-cleaned. The same shape exists in `_cleanup_forced_timelapse` (#1397) walking the four timelapse dirs. **Fix:** `bambu_ftp.py` now exports a `DeleteResult` enum (`DELETED` / `NOT_FOUND` / `FAILED`). `BambuFTPClient.delete_file` detects the 550 case via `isinstance(e, ftplib.error_perm) and str(e).startswith("550")` (same pattern already used in the download path for the symmetric `FileNotOnPrinterError` sentinel from #972). `delete_file_async` now returns `DeleteResult`. Both post-finish cleanup helpers (`main.py::on_print_finished` SD branch + `_cleanup_forced_timelapse`) only WARN when at least one candidate returned `FAILED`; an all-`NOT_FOUND` outcome logs DEBUG ("nothing to delete — printer likely self-cleaned"). The cleanup helper also no longer burns the 2 s × 3 retry budget on a `NOT_FOUND` result (550 will never recover by waiting); only `FAILED` triggers backoff. `DELETE /printers/{id}/files/...` returns 404 (not 500) on `NOT_FOUND`, more accurate for the user-facing UI. Three other production callers (`print_scheduler` pre-upload delete, two `background_dispatch` fire-and-forget cleanups) are unchanged at the call site — they discard the return value. **Tests:** `test_delete_file` and `test_delete_file_async` in `test_bambu_ftp.py` switched to the enum (3 cases each). 2 new regression tests in `test_cleanup_forced_timelapse.py`: `test_forced_no_warning_when_every_dir_returns_not_found` pins the #1721 path (every candidate dir → 550 → no WARNING, one DEBUG summary), `test_forced_warns_when_any_dir_returns_failed` pins the counterpart (any FAILED keeps the WARNING — that's the signal the maintainer wants). `caplog` asserts the log record's level + content directly. Full backend suite 5941/5941 green; ruff clean; frontend untouched (rebuild + i18n parity confirmed clean per `feedback_run_all_ci_checks`). No migration, no new i18n keys, no schema changes. - **Virtual printer external spool (`vt_tray`) went "invalid" right after a slicer filament pick (#1622 round 5, reported by @shaddowlink)** — On a P1S in non-proxy VP mode, the reporter picked a filament for the external spool slot in BambuStudio's Device tab and the slot immediately rendered as invalid (color only, no profile, no K-profile, no nozzle temps), but recovered after a virtual-printer reload. AMS slot picks worked correctly. Wire dumps (BAMBUDDY_VP_DUMP_WIRE=1) captured the asymmetry: the bridge's outgoing 1 Hz cached-as-base push delivered `vt_tray = {tray_info_idx, tray_color}` — 2 fields — where a real P1S sends ~20 (`tray_type`, `state`, `remain`, `k`, `n`, `cali_idx`, `nozzle_temp_min/max`, `tray_uuid`, `xcam_info`, ...). The same `_out.json` showed AMS slots with the full 24-field dict because `_merge_ams_dict` deep-merged them. **Root cause:** Bambu firmware sends a partial `vt_tray` incremental right after acknowledging an `ams_filament_setting` for `ams_id=255` (external spool) — carrying just the fields the slicer's pick changed. The round-4 per-field accumulate (#1622 / da799447) carried over prev keys NOT present in new, but `vt_tray` IS present in new, so the cached dict was REPLACED wholesale with the 2-field partial. The next 1 Hz cached-as-base push handed the slicer the stripped vt_tray; BambuStudio rendered the slot as invalid. Reloading the VP forced a reconnect → pushall → full vt_tray restored, and the cycle repeated on the next pick. **Fix:** `mqtt_bridge.py::_on_printer_raw` now applies the same per-field accumulate one level deeper: for every top-level key whose prev AND new are both dicts, overlay new onto prev rather than replace. `ams` is explicitly excluded (already deep-merged by `_merge_ams_dict`). The same overlay protects `device`, `online`, `upgrade_state`, `ipcam`, `upload`, `net` against future firmware partials with the same shape; the `net.info` IP rewrite path is unaffected because `_rewrite_net_info_ips` runs against `new_state["net"]` before caching and the rewritten list overrides the cached one on overlay (only `net.conf` and friends, when sent without `info`, draw from prev now). **Tests:** new `test_partial_vt_tray_update_overlays_onto_cached_full_dict` regression case in `test_vp_mqtt_bridge.py::TestPushStatusCache` constructs the exact P1S wire shape — pushall with the full ~20-field vt_tray, followed by the `{tray_info_idx, tray_color}` partial that shaddowlink's dump captured — and asserts `tray_type`, `state`, `remain`, `k`, `n`, `cali_idx`, `nozzle_temp_min/max`, `tray_uuid`, `id` all survive while the two incoming fields take their new values. All 53 bridge tests stay green; 287/287 across the broader VP test surface (mqtt_bridge / mqtt_server / vp_wire / virtual_printer); ruff clean. Bridge code path only; no migration, no new i18n keys, no frontend touch. - **Library G-code preview returned raw ZIP bytes as `text/plain` for sidecar-sliced rows (#1709, root cause + fix from @yanglei1980)** — `slice_and_persist` writes its output as a `.gcode.3mf` (a ZIP container with embedded G-code) but persisted the LibraryFile row with `file_type="gcode"`. The G-code preview endpoint at `library.py::get_gcode` short-circuits on `file_type == "gcode"` and streams the on-disk bytes with `media_type="text/plain"`, so every preview of a sidecar-sliced row handed the embedded viewer the raw ZIP body (`PK\x03\x04…`) instead of toolpath text — the viewer rendered nothing. External-folder scans (#1600) already typed `.gcode.3mf` rows correctly and hit the unzip branch, so the bug was specific to the sidecar slice path. Plain `.gcode` uploads were unaffected (their on-disk bytes really are text). **Fix:** (1) forward — `slice_and_persist` now persists `file_type="gcode.3mf"`, matching what `_classify_file_type` returns for the `.gcode.3mf` extension and what external-scan rows already use; (2) back-compat — `get_gcode` also routes to the unzip branch when the filename ends with `.gcode.3mf`, so rows already written under the bug self-heal on first preview without a DB migration. **UI gates:** three frontend call sites that gated badge colour or the preview-eye icon on `file_type == "gcode"` were extended to also accept `"gcode.3mf"` — `FileManagerPage.tsx` badge + viewer-affordance gate, `ProjectDetailPage.tsx` badge — so the new typing doesn't regress visuals. The print / queue / slice action buttons use filename-based helpers (`isSlicedFilename`, `isSliceableFilename`) that already accept `.gcode.3mf`, so they need no change. **Tests:** new `test_library_get_gcode_recovers_legacy_gcode_type_for_3mf` regression case in `test_library_api.py` constructs a row with `file_type="gcode"` + `.gcode.3mf` filename pointing at a real ZIP, asserts the response is `text/plain`, contains `G28`, and does NOT start with `PK` — pins the legacy-row recovery path. Existing `test_library_get_gcode_endpoint_accepts_compound_file_type` continues to cover the forward path. Full backend suite 5920/5920 green; ruff clean; frontend ESLint + `npm run build` clean; FileManagerPage / ProjectDetailPage / FileManagerExternalFolder vitests 69/69 green; i18n parity unchanged (no new keys). PR #1709 closed for CONTRIBUTING.md non-compliance (branched from main, no issue, template incomplete); root cause + fix shape preserved here on `dev`. - **Cloud + Orca Cloud preset resolver: pin `type` and `from` to CLI-accepted values (#1712 follow-up, reported by maziggy on the Mecha Mewtwo slice)** — Removing bundle mode (entry above) routed every slot through the cross-tier preset resolver. Cloud-tier presets surfaced two latent shape mismatches that bundle dispatch had been masking by materialising preset JSONs from `.bbscfg`-on-disk. (1) **`type` field**: Bambu Cloud labels presets with `type: "printer"` / `"print"` / `"filament"`, but the BambuStudio CLI's `--load-settings` parser only accepts `"machine"` / `"process"` / `"filament"`. The user's first failing slice produced `operator(): unknown config type print of file preset.json in load-settings` with exit code -5; the sidecar surfaces this as a generic "The input preset file is invalid and can not be parsed." (2) **`from` field**: Bambu Cloud's filament detail endpoint routinely ships presets with empty `from` (or no `from` at all). The CLI's compatibility check rejects either with `operator(): file ... 's from unsupported` (the double space in stderr = empty value). Same -5 exit, same generic "input preset invalid" surface. The sidecar's `normalizeFromField` already maps `"User"` / `"System"` → `"system"`, but it doesn't touch empty / missing values. **Fix:** `_resolve_cloud` and `_resolve_orca_cloud` now force `type = _SLOT_TO_PROFILE_TYPE[slot]` and `from = "system"` on the payload before `json.dumps`, mirroring what `_resolve_standard` already does for the standard-tier stub. Both fields are unconditionally rewritten — idempotent on already-correct payloads, and pinning to "system" is consistent with how Bambuddy presents these post-flatten presets to the CLI (no parent walk needed, the cloud detail comes back fully expanded). **Tests:** new `test_cloud_rewrites_type_field_for_cli` (7 parametric cases covering all six type-name variants Bambu Cloud emits plus the missing-type case), `test_cloud_pins_from_field_to_system` (4 cases: empty, already-system, GUI User, GUI System), and `test_cloud_synthesises_from_field_when_missing` (the actual Mecha Mewtwo failure shape) pin the resolver-level contract. Existing happy-path assertions for `_resolve_cloud` / `_resolve_orca_cloud` updated to include the new fields. 28/28 preset-resolver tests green, full backend suite 5919/5919 green, ruff clean. **What this can NOT recover:** if Bambu Cloud later starts emitting a `from` value other than empty / "User" / "System" that genuinely means something (e.g. "project"), Bambuddy will silently flatten it to "system" too. We accept that trade-off because the alternative is leaving "input preset invalid" failures on every cloud slice, and "system" matches how the sidecar's own resolver normalises the post-flatten state. - **Virtual printer cache drained capability/lifecycle fields between pushalls, greying out Device-tab UIs (#1622 round 4, reported by @shaddowlink)** — Reporter on a P1S in archive mode saw the AMS-slot filament dropdown empty and the "Manage calibration data" UI disabled in BambuStudio's Device tab, while the same panels worked correctly on his H2D. After three rounds of triage on the printer-side payload (which traced clean — bridge passes `vt_tray` byte-identical, `tray_info_idx` resolves, AMS slots populate), the actual asymmetry surfaced in the bridge cache dumps: P1S cached `print` state contained 17 top-level keys; H2D contained 99. The missing fields were exactly the capability/lifecycle gates BambuStudio reads to decide which Device-tab UIs to enable (`cali_version`, `print_type`, `gcode_state`, `mc_print_stage`, `mc_stage`, `device`, `cfg`, `home_flag`, the `mc_*` family, fan speeds — ~80 fields). **Root cause:** Bambu firmware sends a full top-level field set in pushall responses (on `pushall` request / printer reconnect) and ~1 Hz incrementals carrying just what changed (typically temps, fan, wifi, status). `_on_printer_raw` in `mqtt_bridge.py` cached the latest push as `new_state = copy.deepcopy(print_data)` — replacing the prior cache wholesale — then re-merged only a hand-picked allowlist (`_SLICER_VISIBLE_STICKY_KEYS`) of 14 keys back from prev. The allowlist covered the #1371 / #1387 / #1228 / #1558 failure modes but missed capability/lifecycle fields entirely, so every 1 Hz incremental drained ~80 fields out of the cache and the slicer's gated UIs flipped off as soon as the cache thinned. The code comment claimed the cache "mirrors the same preservation pattern Bambuddy uses for its own internal state in bambu_mqtt.py" but it didn't: internal state is updated per-field (`if "X" in data: self.state.X = ...`), never drops what it's seen, and accumulates monotonically. **Fix:** replace the allowlist-preserve with per-field accumulate. For every key in the prior cache, carry over verbatim when the incoming push omits it; let new values overwrite when present. The `_merge_ams_dict` deep-merge for partial `ams` blobs stays (#1387 / #1371 regression guards still pass). `_SLICER_VISIBLE_STICKY_KEYS` is removed entirely — the new logic is a strict superset of every case the allowlist handled. **Why most P1S users don't hit it:** timing. The typical workflow is connect → BS issues pushall → cache fills → click Device tab within seconds → UI works. shaddowlink's sequence kept BS idle long enough between pushalls that the cache thinned to incremental-only state before he clicked. X1C users hit the same drain but don't notice — older BS capability spec doesn't gate the same UIs on `cali_version` / `mc_print_stage`. H2D escaped detection because his captures happened to land close to a pushall reply (cache still fat). **Tests:** new `test_incremental_push_preserves_non_allowlisted_capability_fields` regression case in `test_vp_mqtt_bridge.py::TestPushStatusCache` constructs a full push with `cali_version` / `print_type` / `gcode_state` / `mc_print_stage` / `mc_stage` / `device` / `cfg` / `home_flag`, follows it with a temps-only incremental, and asserts every capability field survives. All 51 existing bridge cache tests stay green — same behaviour for the allowlist subset, plus the formerly-dropped fields. Bridge code path; no migration, no new i18n keys. - **Force-color-match checkbox missing when scheduling against a specific printer (#1717, reported by @SamNuttall)** — The Print Queue's schedule dialog hides the per-slot "Force color match" checkbox in the "Specific printer" path. Picking "Any A1" (model-mode dispatch) renders `FilamentOverride` which carries the checkbox, but picking a single printer renders `FilamentMapping` instead — a separate component that had no force-match UI. The dispatcher in `print_scheduler.py:535` already honours `force_color_match` regardless of how the queue item was created (the flag survives end-to-end on the `filament_overrides` payload `buildFilamentOverridesArray` constructs in `PrintModal/index.tsx:613`), so this was a pure UI gap — printer-mode users could not request the safety guard from the modal even though the backend would have respected it. **Fix:** `FilamentMapping` accepts new optional `forceColorMatch` + `onForceColorMatchChange` props mirroring `FilamentOverride`'s shape; it renders the same ``-iconed checkbox under each filament row when a handler is provided. `PrintModal/index.tsx:1100` passes the existing `forceColorMatch` state and a `setForceColorMatch` setter through — same state object both modes write into, so toggling between modes preserves what the user selected. No new i18n keys (the existing `printModal.forceColorMatch` key already ships in all 11 locales). The checkbox is suppressed when no handler is wired (avoids dead UI in callers that don't manage the flag). **Tests:** new `renders the per-slot force-color-match checkbox in printer mode (#1717)` case clicks the checkbox and asserts `onForceColorMatchChange(slotId, true)` fires; companion `omits the force-color-match checkbox when no handler is provided` case pins the absent-handler branch. Existing FTS dropdown-filter tests stay green. `FilamentMapping.test.tsx` 4/4 green; combined PrintModal + FilamentOverride + FilamentMapping suite 73/73 green; eslint clean; frontend build clean; i18n parity 5120 leaves × 11 locales green. - **In-app updater fails when DATA_DIR is on a separate mount from the install (#1715, reported by @francescocozzi)** — Native installs that follow the systemd template `WorkingDirectory=/opt/bambuddy` with `Environment="DATA_DIR=/srv/bambuddy/data"` (or any layout where `DATA_DIR` is not a subdirectory of the install path) couldn't apply in-app updates. Every git step in `_perform_update` (`remote get-url`, `remote set-url`, `fetch`, `reset --hard`) used `cwd=settings.base_dir`, and `safe.directory` was pointed at `base_dir` too. On the standard install (DATA_DIR=INSTALL_PATH/data) this happened to work by accident — git walks up from a subdirectory of the repo to find `.git` — but on a separate-mount layout the data dir is not under the install, the walk-up has nowhere to go, and every operation returns "fatal: not a git repository." Even on the standard install `safe.directory={base_dir}` was wrong (it must equal the repo root git discovers, not the data dir), surfacing on hardened systemd units as "fatal: detected dubious ownership." **Fix:** route every git subprocess in `_perform_update` and `_origin_points_at_repo` through `cwd=settings.app_dir` (the working tree), and set `safe.directory={app_dir}` to match. `app_dir` is now resolved once at the top of `_perform_update` instead of lazily re-resolved before the pip step. The `base_dir` parameter on `_origin_points_at_repo` is renamed to `app_dir` so the signature documents the contract. The pip-install step keeps `cwd=app_dir` (unchanged — that step was already correct). **Tests:** new `test_perform_update_runs_git_in_app_dir_when_data_dir_on_separate_mount` integration case constructs a sibling-paths layout (`tmp/opt/bambuddy` + `tmp/srv/bambuddy/data` — the exact #1715 shape), mocks `asyncio.create_subprocess_exec` to capture every call's cwd, and pins (a) every git subprocess runs with `cwd=app_dir`, (b) the embedded `safe.directory=` config equals `app_dir` on every git call. The existing pip-cwd test stays green (pip's cwd was already `app_dir`). Existing SSH-origin-preserve + origin-rewrite + reset-target tests stay green (they don't assert on git cwd). Full `test_updates_api.py` 21/21 green; ruff clean. **Credit:** root cause + fix shape from francescocozzi via PR #1716 (couldn't be merged as-is — that branch had drifted off an older `dev` and pulled in unrelated upstream commits including a version regression). - **SliceModal preset-lookup precedence + cross-tier dedup + signed-out banner (#1712, reported by @IndividualGhost1905)** — After the Orca Cloud integration shipped (2026-06-04), every user — including Bambu-Cloud-only / Bambu-Studio-preferred users — got Orca Cloud as the top tier across the SliceModal preset picker, the per-preset auto-pick scoring, the dropdown's optgroup rendering, the AMS slot picker's filament sort, and the backend dedup precedence. A Bambu-Cloud / X1C user reported seeing his Bambu Cloud profiles disappear from auto-pick because Orca Cloud's empty tier shadowed them. The cross-tier dedup (introduced with #1150 and inherited as-is by the Orca change) compounded the problem: a name that existed in multiple tiers showed in only ONE group, so a user with a local-imported and Orca-synced "Bambu PLA Basic" never saw the Orca copy as a picker option — even though they curate both sources. And the cloud-status banner (`CloudStatusBanner`) nagged signed-out users with a permanent *"Sign in to Orca Cloud (Profiles → Orca Cloud) to see your Orca presets"* at the top of every SliceModal open — even after a user had explicitly logged out of Orca Cloud. The Bambu Cloud banner had the symmetric problem. **Fix — order:** precedence is `local > orca_cloud > cloud > standard` across `SliceModal.tsx` (`SLICE_MODAL_TIER_ORDER` + `TIER_BONUS` + dropdown tier list), `ConfigureAmsSlotModal.tsx` (`sourceOrder`), and docstrings in `slicer_presets.py` / `schemas/slicer_presets.py` / `client.ts`. Local imports win (the user did them on purpose), Orca Cloud comes next, Bambu Cloud, bundled fallback last. The order drives auto-pick + visual group order; it does NOT hide profiles. **Fix — no dedup, full lists:** `_dedupe_by_name` is replaced by `_enrich_cloud_metadata`, which returns every tier's full preset list across all three slots (printer / process / filament) — a name in local AND orca_cloud AND cloud renders in EACH of their groups so the user can pick any source. The only work the function still does is filament-metadata backfill: a Bambu Cloud filament without its own `filament_type` / `filament_colour` inherits values from a same-named local / orca_cloud / standard entry so it can still score in `pickFilamentForSlot`. Printer + process presets carry their metadata inline and need no enrich. Frontend code already iterates tiers in priority order and surfaces every entry — no change needed there once the backend stops filtering. **Fix — banner:** `CloudStatusBanner` now silently returns null on `not_authenticated` in addition to `ok` — applies symmetrically to both Bambu and Orca cloud banners. `expired` (token broke) and `unreachable` (network / service down) still surface — those are real breakage states a previously-signed-in user needs to see. Sign-in lives on the Profiles page; the modal doesn't need to advertise it. The `slice.cloud.notAuthenticated` / `slice.orcaCloud.notAuthenticated` i18n keys stay in the locale files (dormant) so re-enabling later doesn't need a re-translation pass. **Fix — ConfigureAmsSlotModal source badges:** before this change, the per-row source badge fired three branches independently — `local` got a green "Local" badge, `builtin` got an amber "Built-in" badge, and a blue "Custom" badge appeared on top of those when `isUser` was true. Since ALL Orca Cloud entries are marked `isUser: true` and Bambu Cloud user presets also get the same flag, the result was visually inconsistent: Orca Cloud rows showed *only* "Custom" (no source identification, no way to tell them from Bambu Cloud user presets), Bambu Cloud built-in rows had NO badge at all, and the "Custom" badge collided with the actual source. Replaced with a single source badge per row: green "Local", purple "Orca Cloud" (new), bambu-blue "Bambu Cloud" (new), amber "Built-in". One badge per row; one colour per source; the `isUser` distinction within the Bambu Cloud tier is dropped (the preset name itself carries the "is this user-authored" signal). Same change in both render blocks (the filament-list code is duplicated in the modal — kept the duplication local rather than refactoring out a helper component in this PR to keep the diff tight). i18n: 2 new keys (`configureAmsSlot.orcaCloud`, `configureAmsSlot.bambuCloud`) translated to all 11 locales — both are brand names, already on the per-locale `IDENTICAL_TO_EN_ALLOWED` lists so the parity check is satisfied without per-locale variants. The dormant `configureAmsSlot.custom` key stays in the locale files. **Tests:** `TestEnrichCloudMetadata` replaces `TestDedupeByName` (5 cases): regression guard pinning that a name in all four tiers appears in EACH (not just local), tier order preserved within a tier, Bambu Cloud filament metadata backfilled from local, backfill falls through to orca / standard when local doesn't carry the name, backfill does NOT overwrite Bambu Cloud's own metadata when present. The "renders a sign-in banner when cloud_status is not_authenticated" case flipped to assert no banner appears, with the test name updated to call out the #1712 reason. Backend `test_slicer_presets.py` 47/47 green; `SliceModal.test.tsx` 34/34 green; `ConfigureAmsSlotModal.test.tsx` 24/24 green; ruff clean; frontend build clean; i18n parity 5120 leaves × 11 locales green. - **Telegram (and other image-bearing) finish notification on a reprint-from-archive showed the original print's finish photo instead of the new run's (#1707, reported by @kycrna)** — P2S user reprinted an archived job and observed the Telegram notification arriving with the photo of the *original* print (white box) attached to the completion message for the *new* run (black box). **Root cause:** reprints reuse the source archive row — `register_expected_print` stores the source `archive_id` in `_expected_prints`, and the on-print-start expected-archive promotion branch at `main.py:2207-2245` updates the row's status / started_at / printer_id / subtask_id but never reset `archive.timelapse_path`. Two failure modes cascaded from the stale path: (a) `_scan_for_timelapse_with_retries` early-returns at `main.py:3062` with `if archive.timelapse_path: return` — the reprint's new timelapse MP4 sitting on the printer's SD card was never downloaded, the archive's `timelapse_path` kept pointing at the original run's local file; (b) `_capture_finish_photo_from_timelapse` polls `archive.timelapse_path` and immediately found the *original* video, extracted ITS last frame as `finish__.jpg`, and handed those bytes to `_background_notifications` as `image_data` — which then went out to Telegram via the `sendPhoto` path. The filename was new (so log lines and the archive's `photos` list looked correct), but the pixels were the original run's finish frame. Surface was specific to the timelapse-prefer path: with `data.timelapse_was_active` true and no external camera, `prefer_timelapse_source` was True, which is the exact configuration on P2S with timelapse-on for both runs. External-camera, buffered-frame, and fresh-RTSP fallback paths grab the *current* camera frame, so users on those paths saw correct photos and the bug stayed hidden. **Fix:** at expected-archive promotion, capture and clear `archive.timelapse_path` to None before the commit, and `os.unlink` the stale on-disk video so reprints don't accumulate orphaned MP4s in the archive directory. Photos list is left alone — accumulating one finish photo per run across the archive's lifetime is the right behaviour. The unlink is wrapped in `OSError`-catching best-effort logging so a missing file (manual delete, archive purge, container rebuild with bind-mount drift) doesn't break promotion. The clear-and-unlink runs unconditionally when `timelapse_path` is set, so even if a user has been reprinting under the buggy build for months, the next reprint self-heals. **Tests:** 3 new cases in `test_reprint_clears_stale_timelapse.py` exercise the full `on_print_start` callback through the expected-archive branch — happy path (path cleared + file unlinked), no-prior-timelapse (no-op, promotion still succeeds), missing-stale-file (best-effort unlink doesn't raise). Full `test_print_start_expected_promotion.py` + `test_print_start_assigns_printer_id_to_vp_archive.py` suite (28/28) stays green; ruff clean. - **Connection diagnostic no longer flags `external_storage: fail` on A1 / A1 Mini, which physically have no MicroSD slot (#1703, reported by @MartinNYHC)** — Bug report from an A1 user complained that BambuStudio and OrcaSlicer don't have an "external storage" tick box (correct — there's nothing to toggle, the A1 series ships without a SD card slot at all) while the Bambuddy support bundle simultaneously reported `external_storage: fail` in the printer's connection diagnostic. The two together left the user thinking Bambuddy was wrong about a setting their hardware doesn't have. **Root cause:** the `external_storage` check at `services/printer_diagnostic.py:179-189` reads `state.store_to_sdcard`, which is parsed from MQTT `home_flag` bit 11. On A1 and A1 Mini that bit is never set (no hardware slot, no firmware-side toggle, no slicer-side equivalent), so the value pushes as `False` and the check fell through to `fail` instead of `skip`. **Fix:** new `NO_EXTERNAL_STORAGE_MODELS` frozenset in `utils/printer_models.py` enumerating A1, A1 Mini, and their internal codes (N1, N2S, A04, A11, A12), plus a `has_external_storage(model)` helper that returns False for those and True for everything else (unknown models default to True so the check stays active for any future Bambu model that ships *with* a slot — new no-slot models must be added to the set explicitly). The diagnostic now short-circuits to `skip` before reading `store_to_sdcard` when `printer.model` is in the set. **What this does NOT change:** X1 / X1E / P1S / P1P / P2S / H2D / H2D Pro / H2C / H2S / X2D continue to evaluate `store_to_sdcard` exactly as before — the home-flag-bit-off → `fail` path is still the right signal for them. **The companion FTP-upload-timeout symptom in the same bug report (ftp code 28 from BambuStudio when sending to the proxy VP) is a separate Docker-bridge-mode networking constraint, not addressed by this change.** **Tests:** 8 new cases — `TestHasExternalStorage` (5 cases) pins the model list, internal-code aliasing, case/whitespace normalisation, unknown-defaults-true, and null/empty-defaults-true; `TestExternalStorageCheck` gains `test_skips_on_a1_no_external_storage_slot`, `test_skips_on_a1_mini_no_external_storage_slot`, and `test_still_fails_on_x1c_when_toggle_off` (regression guard that the model-aware skip doesn't accidentally silence the genuine signal on slotted models). Full `test_printer_models.py` + `test_printer_diagnostic.py` + archives integration suite green (172/172); ruff clean. - **AMS slot card surfaced the previous spool's preset name after RFID auto-assigned a new spool (reported with H2D-1 / AMS-B3 / PLA-CF showing as "Bambu PLA Silk+")** — Reporter inserted a fresh Bambu PLA-CF spool into AMS-B3, RFID identified it correctly, but the slot card kept showing "Bambu PLA Silk+" (the name from a PLA Silk+ spool that had occupied the slot back in March). Confirmed in the live data: `slot_preset_mappings` row for `(printer_id=1, ams_id=1, tray_id=2)` was `preset_id=GFSA06_09, preset_name='Bambu PLA Silk+', updated_at=2026-03-15` — three months stale. **Root cause:** `slot_preset_mappings.preset_name` is first in the PrintersPage display chain (`PrintersPage.tsx:3624`) and overrides the spool's own `slicer_filament_name` plus the cloud catalog `cloudInfo.name`. The internal-mode manual-assign path (`inventory.apply_spool_to_slot_via_mqtt`) kept this row in sync, but the internal-mode RFID auto-assign path (`spool_tag_matcher.auto_assign_spool`) skipped it entirely. The Spoolman-mode sync path (`main.auto_sync_spoolman_ams_trays`) also skipped it — same bug shape, latent for Spoolman users who'd never manually configured a slot preset, active for those who had. **Fix — three writers in lockstep via one shared helper.** New `backend/app/services/slot_preset_writer.py` exposes a primitive `upsert_slot_preset` plus two convenience wrappers: `upsert_slot_preset_for_spool` for internal `Spool` ORM objects (local-preset numeric ids → `local_{n}`, cloud ids run through `filament_id_to_setting_id`) and `upsert_slot_preset_for_spoolman_spool` for Spoolman dicts (filament.name → preset_name, tray_info_idx → preset_id). All three call sites — the manual-assign block in `inventory.py:396-438`, the RFID auto-assign tail in `spool_tag_matcher.py:auto_assign_spool`, and the per-tray-sync branch in `main.py:auto_sync_spoolman_ams_trays` — now go through the helper. **Self-heal:** existing stale rows from past spool swaps get rewritten the next time a fresh spool is detected on the same slot. No migration script needed. **What this also covers per `feedback_inventory_modes_parity`:** the bug shape exists in both internal and Spoolman modes, so the patch ships fixes for both inventory paths in the same drop — a Spoolman user with a manually-configured slot preset would have seen the same stale-name behavior after every RFID swap until the row was overwritten through Configure Slot. **Tests:** new `test_slot_preset_writer.py` (6 cases) pins the helper contracts — no-op on empty preset_id, upsert idempotency, Spoolman filament.name → preset_name, fallback to material → tray_sub_brands → tray_type, stale-row overwrite from the Spoolman path, skip when tray_info_idx is unknown. New `test_spool_tag_matcher.py` cases (3) pin the internal RFID-auto-assign path — stale-row overwrite (the exact reporter shape: PLA Silk+ → PLA-CF), fresh insert when no row exists, `local_{n}` formatting for numeric local-preset ids. Total touched-area suite 69/69 green; broader related suite (inventory + spoolman + spool_tag + auto_sync) 767/767 green; ruff clean. - **Stats page Failure Analysis widget rendered raw camelCase keys instead of translated reasons (#1687 follow-up, reported by @IndividualGhost1905)** — After #1687 part 4 shipped the per-row Print Log editor, the reporter classified a couple of failed runs and saw "filamentRunout" / "cloggedNozzle" (the literal camelCase keys) appear under Statistics → Failure Analysis → Top Failure Reasons, while the same rows rendered correctly as "Filament runout" / "Clogged nozzle" on the Print Log table. Surfaced an inconsistency I introduced when shipping the new editor: the new Print Log row editor saves the camelCase key (`filamentRunout`) which is what the new backend PATCH validates against, but the older `EditArchiveModal` was still saving the localised label (`"Filament runout"`) as the value — two formats landing in the same `PrintLogEntry.failure_reason` column from two different UI surfaces. The Failure Analysis widget at `frontend/src/pages/StatsPage.tsx:817` and the per-archive run history sub-table at `frontend/src/components/PrintLogTable.tsx:81` both rendered the raw column value without running it through i18n, so the new key-form values surfaced as literal keys. **Fix — three sites in one drop:** (1) `StatsPage.tsx` and (2) `PrintLogTable.tsx` now wrap the value in `t('editArchive.failureReasons.${reason}', { defaultValue: reason })` — same pattern already used at `ArchivesPage.tsx:3874` for the Print Log table. The `defaultValue` fallback keeps legacy translated-text rows rendering as-is, no regression. (3) `EditArchiveModal.tsx` now saves the camelCase key (`` click." Per the WindowFeatures spec, `noopener` deliberately forces `window.open` to return `null` even on success, so the `if (!win)` fallback fired on EVERY click. Path 1 (window.open) opened the blob tab — on Linux Chromium without an inline PDF viewer the OS saved a random-named copy (the `zo70GhSL.pdf` / `f7w0OcDi.pdf` files in the reporter's screenshot). Path 2 (fallback) downloaded a second copy named `bambuddy-labels.pdf`. Two identical PDFs per click. Fix: drop `noopener,noreferrer`. The blob is same-origin (created via `URL.createObjectURL` from our own fetch response), the destination is a passive PDF preview tab with no script context to abuse `window.opener`, and `noreferrer` is a no-op for blob URLs. After removal, `window.open` returns a real window reference on success → `if (!win)` only fires on genuine popup-block, single PDF per click. Existing 17 vitest cases in `LabelTemplatePickerModal.test.tsx` still pass; the change is comment + one parameter. - **Scheduled local backup time is now interpreted as local time, not UTC (#1602 follow-up)** — Pre-fix: the time-of-day picker in Settings → Scheduled Local Backups stored the value as `HH:MM` and `_calculate_next_run` in `backend/app/services/local_backup.py` interpreted it as UTC (`datetime.now(timezone.utc).replace(hour=..., minute=...)`), so a UTC+3 user who wanted a 21:00 local backup had to enter 18:00. The UI hinted at this with a literal "UTC" label, but it was still surprising. Post-fix: the picker is interpreted in the container's local timezone, resolved from the `TZ` env var via `zoneinfo.ZoneInfo` (same source the Support page's `environment.timezone` already shows). UTC fallback when `TZ` is unset or unrecognised — preserves the legacy behaviour rather than crashing. The UI now shows the resolved zone name next to the time field (`Local time (Europe/Berlin)` / `Yerel saat (Europe/Istanbul)` / etc.) via a new i18n key `backup.localTimeHint` with real translations across all 10 non-English locales, replacing the old `backup.utc` literal. New `timezone` field on `/api/local-backup/status` exposes the resolved zone to the UI. **One-time behaviour change for existing users**: anyone who entered a UTC time as a workaround (per #1602's UTC+3 reporter — "I have to write 18:00 to get 21:00 local") will see the first scheduled cycle after upgrade run at their local TZ offset earlier than expected. Re-enter the time as local once and it's correct from then on. No migration is shipped; the population is small and migrating around a DST boundary would be ambiguous. **Tests** (`backend/tests/unit/test_local_backup.py`): existing 5 cases pinned with `monkeypatch.setenv("TZ", "UTC")` so they don't depend on the test runner's TZ; 5 new — Europe/Berlin local→UTC, Europe/Istanbul (the #1602 reporter's zone) local→UTC, no-TZ-env UTC fallback, unrecognised-TZ UTC fallback, DST spring-forward gap (Europe/Berlin 2026-03-29 02:30 wall-clock doesn't exist) asserting no crash. All 30 tests pass. Frontend i18n parity green at 5007 keys across 10 non-English locales. - **Auto-print end snippets silently dropped on P1S, and modified 3MFs rejected with HMS 0500-4003 (#1516, contributed by @phieb)** — Two compounding firmware quirks made the original #422 G-code injection unusable on the P1S — the most common reporter platform for auto-eject / plate-clear automation. **(1) End snippet dropped after `; EXECUTABLE_BLOCK_END`**: the snippet was appended to the end of `Metadata/plate_N.gcode`, but Bambu firmware (verified on a P1S) does not execute G-code that sits **after** the `; EXECUTABLE_BLOCK_END` marker. An auto-eject sweep injected to clear the plate ran on every other Bambu model but silently no-op'd on P1S — print finished, plate stayed loaded, the next queued copy stalled behind it. The injection now anchors the end snippet **before** `; EXECUTABLE_BLOCK_END` so it sits inside the executed block, after the printer's own machine-end sequence (cooldown / M104 S0 / etc) but before the firmware stops parsing. Files without the marker — older slicer versions, non-Bambu sources — keep the existing append-to-EOF behaviour with a warning log; the test suite pins both paths. **(2) Stale `.gcode.md5` sidecar rejected by P1S firmware**: every plate carries a `Metadata/plate_N.gcode.md5` sidecar that the P1S validates against the gcode body on load. Rewriting the gcode without refreshing the hash made the P1S reject the file with `HMS 0500-4003 "unable to parse"` and abort the print — exactly when the injection had succeeded. `inject_gcode_into_3mf` now recomputes the sidecar from the exact bytes about to be written and re-packs it into the 3MF in the same pass, matching Bambu's on-disk format exactly (uppercase hex, 32 chars, no trailing newline). The MD5 is a firmware integrity check, not a security primitive, so the call is flagged `hashlib.md5(..., usedforsecurity=False)` to keep ruff S324 / Bandit B324 clean. 3MFs **without** an `.md5` sidecar member (older files, manual hand-builds) do not gain one — inventing a member could surprise older firmware that doesn't expect it; if the source had no sidecar the firmware wasn't validating it anyway. Non-target zip members keep their original compression intact (the P1S preview parser chokes on re-DEFLATEd PNGs that the source had stored uncompressed). **Live-tested on a P1S** with `{max_layer_z}` placeholder substitution: injection happens, the file loads cleanly, the end snippet executes, the recomputed hash validates against the modified body. **Tests** (`test_gcode_injection.py`): 4 new cases in `TestMd5SidecarRecompute` (sidecar matches the exact gcode bytes after injection, uppercase-hex-no-newline format parity with Bambu's on-disk shape, no `.md5` member is invented when source lacks one, non-target zip member compression is preserved) + 1 new case `test_end_lands_before_executable_block_end` pinning the in-block placement when the marker is present + 1 case renamed from `test_end_still_appended_at_eof` to `test_end_falls_back_to_eof_without_block_marker` to reflect that EOF append is now the **fallback** path, not the primary one. 198 VP + gcode_injection tests in the slice green; full backend 6167/6167; ruff clean. - **Reprint with quantity > 1 + auto-print G-code injection now injects *every* copy, including the first (#1516, contributed by @phieb)** — since auto-print injection landed (#422), ticking **Inject auto-print G-code** in the Reprint dialog only applied to copies 2…N: the first copy was dispatched immediately via the direct reprint path, which bypasses the scheduler that performs injection, so it printed *without* the start/end snippets. For auto-eject / plate-clear setups (Farmloop, SwapMod, AutoClear, Printflow 3D) this left the first copy stuck on the plate, blocking the injected copies queued behind it. When injection is enabled and quantity > 1, the Reprint flow now queues *all* copies so each one is dispatched — and injected — by the scheduler. Behaviour with injection off is unchanged (first copy still prints immediately, the rest queue), as are the single-copy reprint, Add-to-Queue, Edit-queue-item, and stagger paths. ## [0.2.4.5] - 2026-06-03 ### Added - **System theme detection — sidebar toggle and Settings selector follow OS dark/light preference (#1418, contributed by @TempleClause via PR #1501)** — `ThemeMode` gains a third value `'system'` alongside the existing `'dark'` / `'light'`. The provider listens to `window.matchMedia('(prefers-color-scheme: dark)')`, tracks the OS preference in real time, and exposes a new `resolvedMode: 'light' | 'dark'` to consumers — the actual rendered theme after resolving system → OS preference. Layout's sidebar toggle now cycles `dark → light → system → dark` with the icon hinting at the next stop (`Sun`→`Monitor`→`Moon`); the existing logo selection and the dark/light "active" panel highlight in Settings switched from `mode` to `resolvedMode` so they always reflect what's actually painted, regardless of whether the user chose explicitly or inherited from the OS. Settings → Appearance gained a 3-button Dark / Light / System selector (border-green-keys-off-`mode` so System actually highlights System even when it resolves to dark), with a "Settings saved" toast on click matching the adjacent Background/Accent/Style selects. Existing users' persisted `theme-mode` is untouched — anyone on `dark` or `light` stays there and simply gains an extra stop in the cycle; new installs default to `dark`. **Review-caught fixes shipped in the same PR**: (a) the project's `__tests__/setup.ts` mocked `window.matchMedia` with `vi.fn().mockImplementation(...)`, which `vi.restoreAllMocks()` in three test files reset to "return undefined" — pre-PR nothing called `matchMedia` at render time so the wipe went unnoticed, this PR was the first caller and broke 23 existing tests. Rewritten as a plain function (`Object.defineProperty(window, 'matchMedia', { writable: true, value: (query) => ({...}) })`) so `restoreAllMocks` can't touch it. (b) `themeToggleHint` had previously only been updated in `en.ts`; real translations now ship in all 8 non-English locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW) describing the 3-state cycle without referencing the old sun/moon icon pair. (c) PR description reworded to honestly call out the sidebar cycle change as a behaviour change for every user of the toggle (`dark → light → system` now intercepts where users previously got `dark → light → dark`), with the persisted-preference-unchanged caveat made explicit. (d) New i18n key `nav.switchToSystem` with real translations across all 9 locales (`'Switch to system mode'` / `'Zum Systemmodus wechseln'` / `'システムモードに切替'` etc.). **Tests**: 11 new in `ThemeContext.test.tsx` (systemPreference inits from `matchMedia.matches`, change event updates state, resolvedMode follows explicit mode vs systemPreference per `mode` value, dark class applied based on resolved mode, `toggleMode` cycles dark→light→system→dark); 1 new in `Layout.test.tsx` (toggle button title attribute walks the cycle); 4 new in `SettingsPage.test.tsx` (all three buttons render, active green border keys off `mode`, click switches mode, click fires toast). 26 previously-broken tests in `AddNotificationModal.test.tsx` + `NotificationProviderCardStockAlerts.test.tsx` + `CameraTokensPage.test.tsx` pass again post-`setup.ts` fix. Frontend build clean (2682 modules); i18n parity green at 4995 keys × 9 locales (+1 from `switchToSystem`). Contributor handled the entire round-1 review (matchMedia mock, locale parity, PR honesty, full test coverage, toast parity, `.map()` refactor for the button group) in a single revision push, no follow-ups deferred. - **MQTT auth rate-limit on the virtual printer** — Bambuddy's VP exposes an 8-char access code via the slicer-facing MQTT server on port 8883. Without a rate limit the code is brute-forceable by anyone who can reach the VP's bind IP (LAN, Tailscale, or any other tunnel the user chose to expose). The new per-IP limiter records each failed CONNECT auth attempt and rejects further CONNECTs from that IP once 5 failures occur within a 60 s window. The window is sliding (not cumulative), recovers automatically after expiry — no manual unblock — and successful auth clears the IP's prior failure history so a user who fat-fingered their code 3 times then got it right isn't penalised on their next reconnect. Per-IP tracker uses `time.monotonic()` so wall-clock jumps can't extend or shorten the window unexpectedly. Constants `_AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5` and `_AUTH_RATE_LIMIT_WINDOW_SECONDS = 60.0` are module-level for ops tunability. 5 unit tests in `test_vp_mqtt_server.py::TestAuthRateLimit` pin the under-limit/at-limit/window-recovery/multi-IP/success-clears semantics. - **Per-slicer MQTT response routing for multi-slicer VP setups** — Pre-fix: when slicer A sent `extrusion_cali_get` (or any other bridge-forwarded command) to a non-proxy VP bound to a target printer, the printer's response was fanned out to **every** connected slicer — leaking slicer A's response into slicer B's command stream. Slicers ignore responses to sequence_ids they didn't send, but the leak is still wrong and could confuse multi-slicer-host setups (workstation + laptop both connected to the same VP). The fix records `sequence_id → originating client_id` in `SimpleMQTTServer._pending_requests` on the way out and looks it back up in `push_raw_to_clients` on the way in, routing the response only to that one client. Falls back to broadcast for printer-initiated unsolicited pushes (push_status etc. — every slicer expects these) and for sequence_ids the map never saw recorded (covers slicers subscribing mid-flight). Bounded at 256 entries with FIFO eviction so a slicer that sends commands without ever consuming responses can't leak memory. 6 unit tests in `test_vp_mqtt_server.py::TestPendingRequestRouting` cover seq-id capture across nested blocks, lookup-pops-entry semantics, FIFO eviction at cap, malformed-payload fallback, and broadcast on unrecorded seq. - **H2D Pro virtual-printer support (experimental — needs field confirmation)** — Added SSDP model codes `O1E` and `O2D` to `VIRTUAL_PRINTER_MODELS` and matching `09400A` serial prefixes to `MODEL_SERIAL_PREFIXES` so the H2D Pro shows up in the Add Virtual Printer model dropdown and advertises a model code distinct from H2D's `O1D`. The codes were transcribed from the project's model-codes reference but have not been validated against a live H2D Pro's SSDP response. Anyone with an H2D Pro who picks this from the dropdown should confirm BambuStudio recognises the VP correctly; if not, the code values need a one-line correction and a follow-up release. - **VP child-service readiness barrier** — Pre-fix: `VirtualPrinterInstance.start_server` spawned each child sub-service (FTP, MQTT, Bind, SSDP) as a `asyncio.create_task` and returned immediately. `is_running` then reported `True` even though the child sub-services' sockets were still in the gap between `asyncio.create_task(...)` and the inner `asyncio.start_server` returning. A caller racing the start (the diagnostic route, the VP-card UI poll, an integration test) could see `running=pass` while `port_ftps=fail`. Each child now exposes a `ready: asyncio.Event` that's set after the actual socket bind, and `start_server` awaits all of them with a bounded 5 s timeout. If a child hangs binding, the timeout logs a `Sub-service didn't bind within 5s: ...` warning and the VP continues — the existing task-tracking still catches the failure on the next iteration. The 5 s ceiling is well above any legitimate bind on healthy hardware; on a Pi 3 with a congested SD card it's tight but bounded. ### Changed - **Bug-report template: tightened fields + new Area dropdown to cut invalid-issue triage load** — 170 issues have been closed with the `invalid` label (61 of them in the last 30 days alone — roughly 1 in 5 of all closed issues), nearly always because the reporter hadn't run the in-app diagnostics or checked the documented troubleshooting page. The template now forces engagement with the tools that were already shipped. **Form changes** (`bug_report.yml`): (a) the "I ran the Connection Diagnostic" checkbox flipped from `required: false` to `required: true`, so the form blocks submission until the reporter has actually used the diagnostic (or knowingly lied — higher friction than reading the doc); (b) the Support Package textarea is now `required: true` instead of optional, with the field's prompt rewritten to "Drag the .zip here, or explain why you cannot attach one" so users without a working Bambuddy still have a path; (c) a new required "Troubleshooting steps already taken" textarea sits between Steps to Reproduce and the printer-model dropdown, asking which wiki pages were checked and which in-app diagnostics were run — empty answers can't submit, which produces either real evidence or an admission that nothing was tried (both of which are useful for triage); (d) the pre-form markdown intro now spells out the "search → wiki → diagnostic → support package" sequence with a citation of the 1-in-5 stat so reporters understand the *why* before they reach the fields; (e) the final-checks list grew from one to three required confirmations (searched issues + checked troubleshooting wiki + ran Connection Diagnostic for connection/printing/camera bugs), with the wiki-checked confirmation linking to the rendered troubleshooting page. **Bug categorization** (the gap that motivated the rewrite): the old single `Component` dropdown only carried `Bambuddy / SpoolBuddy / Both` — useless for area triage. Replaced with TWO required dropdowns: `Product` (Bambuddy / SpoolBuddy) and `Area` (15 options covering the actual feature surface — connection, dispatch, filament/AMS, slicer, VP, camera, archives, stats, queue, notifications, auth, updates, UI, integrations, SpoolBuddy kiosk, plus an Other escape hatch). **Auto-labeling** (`.github/workflows/auto-label-area.yml`): on every issue open/edit, an `actions/github-script@v7` step parses the Area dropdown out of the rendered issue body (matching the `### Area\n\nValue` block GitHub forms produce) and applies the matching `area:*` label. Tolerant of CRLF, the `_No response_` placeholder, and the issue-edit re-fire path (won't re-add an already-present label). Unrecognised Area values emit a `core.warning` so missed sync between the form and the workflow map shows up in Actions logs. Maintainer hand-off: 15 `area:*` labels need to be created once via `gh label create` (see commit message for the exact commands) — labels referenced by the workflow but missing in the repo cause the `addLabels` call to throw, so this prerequisite is load-bearing. Printer Model dropdown verified against `PRINTER_MODEL_MAP` in `backend/app/utils/printer_models.py` — all 13 current Bambu models present (X1 Carbon / X1 / X1E / X2D / P1S / P1P / P2S / A1 / A1 Mini / H2D / H2D Pro / H2C / H2S), no update needed. YAML syntax validated via Python `yaml.safe_load` for both the template and the workflow. - **VP virtual-printer FTP server: cmd_STOR streams chunks straight to disk instead of buffering the whole upload in memory** — Pre-fix: ``cmd_STOR`` accumulated every chunk in a ``list[bytes]`` and called ``write_bytes`` at the end. Peak RSS for a multi-GB ``.gcode.3mf`` (multi-plate dense prints) was ~2× the file size — chunks held + the ``b''.join`` of them — and could OOM-kill a low-memory host (Pi 3, low-end Synology, etc.). The streaming rewrite writes each 64 KiB chunk to ``file_path.open("wb")`` inline as it arrives, bounding peak memory at one chunk regardless of total upload size. Wire protocol unchanged — same ``150 → 226`` sequence, same destination path, no new verbs, no concurrency guard. The visible difference is that the destination file grows progressively rather than appearing all-at-once on completion; slicers don't ``LIST`` during ``STOR`` so this isn't observable. Same change adds a ``MAX_UPLOAD_BYTES = 4 GiB`` hard cap — a runaway or malicious client can no longer drive RSS or disk to exhaustion. On the cap path the partial file is unlinked so a slicer retry starts clean. 4 unit tests in ``test_vp_ftp_stor.py`` (happy-path bytes on disk + 226, cap-violation 426 + partial cleanup, mid-stream read error cleanup, MAX_UPLOAD_BYTES sanity floor). - **VP virtual-printer FTP passive port range widened from 50000-50100 (101 ports) to 50000-51000 (1001 ports)** — The original range was sized for a single VP. With multiple VPs each running their own FTP server, concurrent passive data connections compete for the 101-port pool and the bind-retry loop's 10 random picks can collide; 1001 ports gives headroom. Only affects the **non-proxy** path (``VirtualPrinterFTPServer.PASSIVE_PORT_MIN/MAX``). The proxy path's ``SlicerProxyManager.FTP_DATA_PORT_MIN/MAX`` stays at 50000-50100 because it pre-binds the printer-side range exactly. Docker bridge-mode users mapping the old range need to update to ``50000-51000:50000-51000`` — `docker-compose.yml`, `install/docker-install.ps1` warning, and the wiki (`docs/getting-started/docker.md`, `docs/features/virtual-printer.md` — port table, two UFW rules, two firewalld rules, Cloudflare-tunnel list, firewall troubleshooting line) all updated with "widened in 0.2.5" notes. Docker host-mode and bare-metal users are unaffected (no port mapping involved). The proxy-mode FTP-data row in the wiki stays at 50000-50100 because that path is unchanged. - **VP MQTT bridge sticky-keys: 7 more fields preserved across incremental pushes** — Pre-fix: when the bridge cached a real printer's ``push_status``, the very next 1 Hz incremental push (which only carries changed temps / fan / wifi_signal) wiped any field not in the sticky-keys allowlist. The cached state lost ``upgrade_state``, ``xcam``, ``hw_switch_state``, ``nozzle_diameter``, ``nozzle_type``, ``online`` and ``ams_status`` after a single tick — BambuStudio's Send pre-flight reads several of these (``upgrade_state.dis_state`` / ``force_upgrade`` in particular) and could refuse Send because the cached push said "unknown firmware state". Same shape as #1228 (storage indicators) and #1558 (live-progress fields) — the cached-branch field-shape parity, not a new mechanism. Sticky-keys carry-forward is now also a ``copy.deepcopy`` (was reference) so a future merge that mutates a carried-forward dict in place can't corrupt both copies. - **VP target-printer DHCP IP / serial refresh now restarts proxy VPs** — Pre-fix: when a target printer's IP changed (DHCP renewal, network reconfiguration), the running proxy VP kept forwarding to the stale IP forever because ``sync_from_db``'s "changed" predicate didn't compare ``proxy_ips`` against the running instance's ``target_printer_ip`` / ``target_printer_serial``. The user had to manually toggle the VP to refresh. Now ``sync_from_db`` re-evaluates the proxy target each cycle and restarts the VP when the IP or serial actually changes — same code path as a config change. If the target printer's DHCP lease cycles frequently this means more proxy restarts, but the alternative was silent breakage; documented in the release-notes for users on flaky-DHCP networks. - **VP queue_force_color_match setting takes effect immediately** — Pre-fix: toggling the per-VP ``Force exact color match`` setting via the UI silently no-op'd because ``sync_from_db``'s "changed" predicate didn't include the field. The user had to restart the process for the new value to land. The predicate now also checks ``queue_force_color_match`` so the running instance gets restarted on toggle. - **VP MQTT client session errors elevated from DEBUG to WARNING** — The outer ``except Exception`` in ``SimpleMQTTServer._handle_client`` was logging at DEBUG, which production deployments default to suppressing. Users reporting "slicer disconnects randomly" then had no signal to pass us. WARNING surfaces it. Inner handlers' expected parser/IO failures stay at DEBUG — only unexpected errors that would otherwise reach the outer catch get visibility. - **VP MQTT periodic status push now logs a one-line per-minute counter per active slicer connection (#1548 follow-up)** — ``_periodic_status_push`` emits ``1Hz status push: N pushes/min to `` at INFO level once per minute per connected slicer (silent when no slicer is attached). The 1 Hz status push was previously silent at INFO; when a reporter sent a support bundle showing an idle disconnect, there was no way to tell whether the push task was actually pushing to that connection or being eaten silently. The counter both confirms the task is healthy for a given client and gives us a concrete data point (N < 60 means pushes were dropped) when triaging future "slicer disconnects on idle" reports. No behaviour change to the push itself. ### Security - **PyJWT bumped to >=2.13.0 to pick up upstream advisory fixes** — `pip-audit` flagged four advisories against 2.12.1 (all fixed in 2.13.0). Pre-bump audit confirmed Bambuddy's usage is unaffected by the five behavioural changes in 2.13.0: (a) HMAC empty-key reject — `_get_jwt_secret()` already guards against `""` at every priority (env-var falsy check, file `len >= 32` gate, generated `secrets.token_urlsafe(64)`); (b) PyJWK header-`alg` must match JWK's algorithm — OIDC decode in `mfa.py:1846` uses `signing_key.key` (raw-key path), not the `PyJWK` wrapper, so this branch doesn't apply; (c) `PyJWKClient` rejects non-HTTP(S) URIs at construction — `mfa.py:1839` constructs from OIDC discovery `jwks_uri` which is HTTPS, and `fetch_data` is overridden so the URI is never fetched anyway; (d) `b64=false` RFC 7515/7797 strictness — no detached-payload usage anywhere in the codebase; (e) per-call `enforce_minimum_key_length` now actually enforces — option not passed anywhere, and the generated 64-byte secret is well over HS256's 32-byte minimum regardless. 229 auth/MFA/OIDC integration tests + 78 auth-related unit tests pass on 2.13.0; runtime encode/decode roundtrip with the real `SECRET_KEY` verified; `pip-audit --strict` reports no remaining vulnerabilities. Pins bumped in `requirements.txt` (PyJWT>=2.13.0) and `pyproject.toml` dev group (pyjwt>=2.13.0). - **WebSocket auth gate + audit-driven hardening sweep — A proactive auth-surface audit run surfaced one critical (`/api/v1/ws` broadcast every printer-status / archive / inventory event to anyone reachable on the HTTP port. All fixed in the same PR. - **API-key permission enforcement is allowlist-based** (reported by @vfxdev) — The three documented API-key scopes ("Read Status", "Manage Queue", "Control Printer") were enforced only inside the legacy `/api/v1/webhook/*` router; every other route used `require_permission_if_auth_enabled` which fell through to a 17-entry admin denylist for API keys and ignored the per-key scope flags. The structural failure modes: (a) any valid key, including one with every scope checkbox unticked, could call print start/stop/pause/resume, queue create/delete/reorder, archive reprint, and every `*_READ` endpoint outside the denylist; (b) `require_any_permission_if_auth_enabled` (`inventory.py`) and `require_ownership_permission` (`print_queue.py`, `archives.py`, `library.py`, `library_trash.py`) returned `None` for any valid key with zero scope check, granting full ownership-modify access to ~10 ownership-gated routes; (c) every new `Permission` enum value added to `core/permissions.py` since the denylist was written silently joined the "API-key-allowed" bucket — fail-open-by-construction, which is exactly how the surface grew over time. **Fix**: `core/auth.py::_check_apikey_permissions` now consumes a new `_APIKEY_SCOPE_BY_PERMISSION` allowlist that maps every non-admin `Permission` to exactly one scope flag on the `APIKey` row; unmapped permissions return 403 ("administrative operations") regardless of which flags are set; the helper is now invoked in all three previously-skipping dependencies. The denylist is retained as a redundant explicit "these are admin" marker plus drift-detection in tests, but the allowlist is the load-bearing check. **Two new scope flags** (per same-PR design discussion): `can_manage_library` (gates `LIBRARY_UPLOAD` / `LIBRARY_UPDATE_OWN` / `LIBRARY_DELETE_OWN` / `MAKERWORLD_IMPORT` — distinct trust level from queue management; rejected the "fold library upload into can_queue" shortcut) and `can_manage_inventory` (gates `INVENTORY_CREATE` / `INVENTORY_UPDATE` / `INVENTORY_DELETE` / `INVENTORY_FORECAST_WRITE` — required because SpoolBuddy kiosks write NFC scans, scale readings, and `/spoolbuddy/devices/{id}/system/command` + `/update` via INVENTORY_UPDATE under the prior denylist gap; 15+ kiosk routes depend on this scope). `CLOUD_AUTH` is now routed through the existing `can_access_cloud` flag (was unmapped → would have admin-denied; the router-level `_cloud_api_key_gate` already does this check, but the route-level dep now fails closed too for defence in depth). **Migration** (`core/database.py::run_migrations`, dialect-branched per [[feedback_sqlite_and_postgres_upfront]]): two new boolean columns added to `api_keys` with `DEFAULT TRUE`, one-shot backfilled to mirror `can_queue` (gated on a new `_api_keys_column_exists` check so the backfill runs only on the migration that adds the column — user-edited values on subsequent restarts are never clobbered). Backfill rationale: a key the operator created as "queue-only" was implicitly relying on the upload+queue and inventory-write workflows the queue scope already let through, so mirroring `can_queue` preserves the operator's intent; a hardened "read-only" key (`can_queue=False`) does NOT silently gain new writes on upgrade. The bundled SpoolBuddy CLI key is explicitly granted `can_manage_inventory=True` because the kiosk itself is the legitimate writer (NFC scan, scale reading, /system/command). **Structural drift backstop**: new `test_every_permission_has_a_classification` fails CI on any future `Permission` added to `core/permissions.py` without an entry in `_APIKEY_SCOPE_BY_PERMISSION` or `_APIKEY_DENIED_PERMISSIONS` — the previous denylist shape allowed silent surface growth, this catches it. **Tests** (`test_auth_apikey_rbac.py`): 78 new — pure-logic `_check_apikey_permissions` matrix covers every (Permission × scope-flag combo) outcome with cross-scope leakage assertions, the structural drift-detection guard, allowlist/denylist disjointness, scope-flag-has-permissions sanity, unknown-perm-string + empty-perm-list fail-closed cases, and the `require_any=True` semantics; the existing denylist-integrity test is updated to reflect that INVENTORY_CREATE/UPDATE are now allowlisted (not admin-only-by-omission) and that operations admin only via omission (PRINTERS_CREATE, LIBRARY_DELETE_ALL, LIBRARY_PURGE, DISCOVERY_SCAN) still 403 with a fully-flagged key. Full 5469-test backend suite green; backend ruff clean. **Frontend**: API-key create dialog gains "Manage Library" + "Manage Inventory" checkboxes with descriptions, the existing list view gains Library and Inventory badges, the cosmetic `apiKeyName`/save-toast flow is unchanged; `api/client.ts` `APIKey` / `APIKeyCreate` / `APIKeyUpdate` types extended. **i18n parity**: real translations for the 6 new keys (`manageLibrary` / `manageLibraryDescription` / `manageInventory` / `manageInventoryDescription` / `libraryBadge` / `inventoryBadge`) across all 9 locales per the [[feedback_translate_dont_fallback]] HARD RULE; parity script green at 5005 leaves × 9 locales. **Wiki** (`features/api-keys.md`): permissions table grows from 5 to 7 toggles with the new scopes and an updated "Principle of Least Privilege" examples list; upgrade notes call out the can_queue-mirroring backfill so operators understand why an existing "queue-only" key keeps uploading after upgrade (and why a "read-only" key still won't); a new explicit "Allowlist model since 0.2.4.5 (GHSA-r2qv-8222-hqg3)" callout documents the shift from denylist to allowlist with the exact previous failure mode (so the audit-trail isn't only in this CHANGELOG). **Out of scope / explicit choice**: did not refactor the SpoolBuddy kiosk routes to use a more semantically-accurate permission than `INVENTORY_UPDATE` for `/system/command` and `/update` (large blast radius across 15+ route decorators, and `can_manage_inventory` matches the trust dimension correctly); did not consolidate the bespoke `require_energy_cost_update` into the new allowlist (its narrow-scope semantics — bypass the SETTINGS_UPDATE denylist via `can_update_energy_cost` — predates this work and is still the right shape for that one electricity-price endpoint). - **Trivy DS-0026 (`Dockerfile.test` missing HEALTHCHECK): silenced via `HEALTHCHECK NONE`** — The test image runs `pytest` and exits; there is no long-running service to probe, so any HEALTHCHECK we added would be cargo-cult noise. `HEALTHCHECK NONE` is the documented Docker directive to explicitly opt out of any inherited healthcheck and is the way Trivy expects projects to signal "this image is not a service." Closes code-scanning alert #813. - **VP access codes now compared with `hmac.compare_digest` (constant-time)** — Pre-fix: both `FTPSession.cmd_PASS` and `SimpleMQTTServer._handle_connect` used Python's `==` operator on the 8-char access code. Constant-time comparison closes the timing-side-channel without changing the protocol surface. Same auth, no UX change. - **VP MQTT brute-force rate-limit per source IP** — 5 failed CONNECT attempts within a 60 s sliding window block further auth attempts from that IP for the rest of the window. Auto-recovers — no manual unblock. Constants `_AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5` / `_AUTH_RATE_LIMIT_WINDOW_SECONDS = 60.0` are module-level for ops tunability. See Added section for full description. - **VP `access_code` no longer leaked in DEBUG logs** — Pre-fix: `PUT /virtual-printers/{id}` logged `body.model_dump(exclude_unset=True)` at DEBUG, which dumped the plaintext access code whenever the user saved a new one. Now the field is redacted (`***`) before the log emission. Violation surfaced by no-secrets-in-logs audit; not exploitable in the field (DEBUG is off by default) but is exactly the kind of leak the rule exists to prevent. - **VP FTP upload capped at 4 GiB (DoS guard)** — `cmd_STOR` now rejects an upload that crosses `MAX_UPLOAD_BYTES = 4 GiB`, deletes the partial file, and replies 426. Without the cap a runaway or malicious client could drive RSS or disk to exhaustion; 4 GiB is well above any realistic multi-plate `.gcode.3mf`. Same code path adds the streaming rewrite (see Changed section for details). - **Path-traversal hardening across the upload / import / file-write surface (routes + services); fifth CI backstop ships alongside** — A private path-traversal report against `POST /api/v1/projects/import/file` traced two attacker-controlled strings being joined to `library_dir` with no resolve + containment check: (a) `linked_folders[*].name` from the request's `project.json` ("Vector A" — an absolute path in this field collapsed `library_dir / "/anywhere"` to `Path("/anywhere")` because pathlib discards the left side when the right is absolute, letting the next `write_bytes` land anywhere the backend could write), and (b) per-entry `zf.namelist()` paths from the ZIP itself ("Vector B" — ZIP filenames carry `..` segments by spec and the join `library_dir / folder_name / relative_path` had no per-component check). Concrete escalation: drop a `.pth` file into the venv's `site-packages` directory for code execution on next service restart; overwrite the JWT signing-secret file to forge an admin token; overwrite `~/.ssh/authorized_keys` or `~/.bashrc` on native installs. **Fix is structural, not just patch the diff** (per [[feedback_dont_dismiss_preexisting]]). New `backend/app/utils/safe_path.py::safe_join_under(parent, *parts)` helper joins under a trusted parent, resolves both sides, asserts `is_relative_to(parent.resolve())`, and rejects up-front empty / null-byte / absolute path components. Wired into `import_project_file` at both vectors. **Adjacent fix from the routes audit**: `GET /api/v1/archives/{id}/photos/{filename}` had NO validation on `filename` and FileResponse-served arbitrary paths — the existing DELETE endpoint at least had a membership check against `archive.photos` (which is UUID-generated on upload), but GET shared neither the check nor any traversal guard. Both GET and DELETE now route through `safe_join_under` for defence-in-depth on top of the membership check. **Second adjacent fix from the services audit**: `ArchiveService.attach_timelapse(archive_id, data, filename)` in `backend/app/services/archive.py:1456` wrote `archive_dir / filename` where `filename` ultimately comes from either a printer's FTP listing (compromised-printer threat model — the printer is part of the trust surface) or the `?filename=...` query param on `POST /api/v1/archives/{id}/timelapse/select`. A malicious printer that returns a directory listing entry with `..` segments could write the timelapse bytes outside the archive directory; the `f.get("name") == filename` gate in the route did not prevent it because the gate is satisfied by whatever the printer claims is on disk. `attach_timelapse` now routes through `safe_join_under(..., http=False)` and returns `False` (logging the rejection) when the join would escape — matching the existing not-found contract of the function rather than raising 400 from inside a background task. **Audit sweep methodology**: AST-walked every Python file under `backend/app/api/routes/` AND `backend/app/services/` for `Path / Name` shapes (the exact shape that produced the original report). 25 additional route-layer sites and 8 additional service-layer sites confirmed safe case-by-case (UUID-generated filenames written by Bambuddy itself, `_safe_filename(...)` / `Path(arg).name` basename-stripped inputs, `os.walk`-discovered names, denylist + format-validated backup names, hardcoded constants iterated through a tuple, DB-stored paths whose write origin already goes through a resolved-and-containment-checked helper). Each safe site got a `# SEC-PATH-OK: ` marker so future audits can trust the inline guard at a glance. Six pre-existing safe-with-marker sites (`library.py` external upload, `archives.py` timelapse output, `projects.py` attachment download/delete, `settings.py` backup extractall) carry the same marker shape. **Fifth CI backstop** `test_route_path_arithmetic_is_safe_joined_or_marked` (`backend/tests/unit/test_no_unsafe_path_joins.py`) AST-walks every Python file in `backend/app/api/routes/` AND `backend/app/services/` and fails the build on any ` / ` join that doesn't either route through `safe_join_under` or carry the marker on the join line. Joins matching the higher-structure shapes (Attribute access, Subscript, f-string, `str(...)` call) are categorically different and out of scope — those are caught by the broader audit sweep, not the regression backstop. The services layer is in scope because it receives values from the routes verbatim AND from external sources Bambuddy has no control over (the printer FTP-listing case above). **Tests**: 17 unit tests for `safe_join_under` covering every escape vector (absolute path, Windows abs path, `..` segments, embedded `..`, null byte, empty string, no parts, non-str, plus legitimate nested-path round-trip); 4 integration tests against `POST /api/v1/projects/import/file` exercising the full FastAPI stack with the verbatim shape from the report (absolute path in `folder_name` → 400 + filesystem assertion that the target file doesn't exist; `..` in `folder_name` → 400; `..` in `relative_path` → 400; legitimate nested ZIP still imports cleanly to guard against the fix being over-strict); 3 unit tests against `ArchiveService.attach_timelapse` exercising the compromised-printer threat model (filename with `..` segments → returns False + no file at the escape target; absolute filename → returns False + no file at `/tmp`; legitimate `timelapse_YYYY-MM-DD_HH-MM-SS.mp4` → returns True + file lands inside archive_dir, guarding against the fix being over-strict). **SECURITY.md** gains a fifth rule + a fifth row in the CI-test mapping table; the rule explicitly names the printer FTP-listing case as in-scope to set the expectation for future services-layer audits. Full 5500+ test backend suite green; ruff clean. ### Fixed - **Print-run log, spool usage history, camera-token list, and SpoolBuddy device "last calibrated" timestamps now render in the browser's local timezone instead of UTC (#1602, reported by @maziggy and confirmed by @IndividualGhost1905 with a UTC+3 reproduction)** — Reporter saw print-run completion timestamps show UTC clock values (e.g. `07:50` instead of the correct local `10:50` for Berlin / `10:50` instead of `13:50` for a UTC+3 host). Same shape as the #504 timezone-offset bug from Feb 2026 — frontend display helpers calling `new Date(isoString)` directly on backend timestamps without timezone indicators. Per ECMAScript, a bare `"2026-06-02T07:50:00"` is parsed as **local time**, so a UTC-stored value gets displayed as if its numeric components were already local — visually identical to UTC. The #504 fix patched 13 sites but missed four: PrintLogTable and SpoolUsageHistory hadn't been written yet; CameraTokensPage and SpoolBuddySettingsPage existed but were overlooked. **Fix** — replaced the bare `new Date(iso)` calls in `frontend/src/components/PrintLogTable.tsx::formatDate` (per-archive Runs list — the reporter's literal symptom), `frontend/src/components/SpoolUsageHistory.tsx::formatDate` (spool usage records), `frontend/src/pages/CameraTokensPage.tsx::formatDate` + `isExpired` (long-lived camera token created / expires / last-used columns), and `frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx::formatDateTime` (SpoolBuddy device "last calibrated") with calls to the shared `parseUTCDate()` helper from `utils/date.ts`, which appends `Z` to naive ISO strings and parses TZ-tagged strings as-is — already used by every other date formatter in the codebase and well-tested (`parseUTCDate` has 4 dedicated test cases covering null/empty/tagged/naive inputs). `isExpired` in `CameraTokensPage.tsx` got the same treatment because comparing a misparsed Date against `Date.now()` would have produced false "not expired" / "expired" results around the TZ-offset boundary. **What this does NOT fix** — the printer-card ETA reporter #1 described (10:50 + 57m showing 09:48). That comes from `formatETA(status.remaining_time)` which is purely client-side (`new Date()` plus minutes from the WebSocket payload, then `toLocaleTimeString([])`); for it to render UTC the browser timezone itself would need to be UTC. That's a browser / OS config issue, not Bambuddy's display. If reporter #1 was actually looking at log timestamps (the same surface reporter #2 explicitly called out), this fix covers it; otherwise their ETA complaint stays a config matter. **Audit confirmed no other regressions** — grepped every `new Date(` call in `frontend/src/` for backend-supplied string arguments. Remaining call sites either pass a number (epoch ms from chart data — `AMSHistoryModal.tsx:327,349`), construct from a numeric date string only with no time component (chart axis labels — `FilamentTrends.tsx:57,129`), use the result only for `.getTime()` arithmetic where the same TZ offset cancels out (sort comparators in `StatsPage.tsx:920`, `ForecastPanel.tsx:101,111,112,141`, `FilamentTrends.tsx:70`), or already wrap in `parseUTCDate(...) || new Date(...)` as defensive fallback (`StatsPage.tsx:607,895`). `FailureDetectionSettings.tsx:359` uses `new Date(ev.timestamp)` directly but the backend (`obico_detection.py:293`) emits `datetime.now(timezone.utc).isoformat()` which includes a `+00:00` indicator so ECMAScript parses it as UTC correctly — unchanged. **No new tests added** — the four `formatDate` / `formatDateTime` helpers are local to their files and the bug is mechanical "use the existing helper"; `parseUTCDate` itself has full coverage in `__tests__/utils/date.test.ts`. Frontend build clean, ESLint zero output, full date-utils + impacted-component vitest suites (103 tests) green, i18n parity green at 5007 leaves × 9 locales. - **Archive card's Print Time + accuracy badge are now consistent for multi-run / multi-plate archives (#1608, reported via an AI-assisted diagnosis that included the failing SQL, file line numbers, and a worked example for archive #65)** — Reporter's case: 3-plate `.gcode.3mf` printed plate-by-plate over 9 runs. Card showed `1h 46m +188%` next to the now-correct `156.7g` / `$9.81`. The 1h 46m = 6364 s = one run's `completed_at − started_at`; the +188% = `print_time_seconds / 6364` − 100 % where `print_time_seconds` is 18354 s (the whole-file estimate the #1593 parser fix correctly stores). The two halves describe different scopes — apples-to-oranges. **Root cause** — `backend/app/api/routes/archives.py::compute_time_accuracy` (line 152) only inspects the archive row's own `started_at` / `completed_at`, which reflect the latest run, while `archive.print_time_seconds` is the sum across plates post-#1593. The existing 5-500 % sanity band catches truly broken values but lets the deterministic N×100% shape through (300% for a 3-plate file). `archive_to_response` calls `compute_time_accuracy` on every list / detail / search / project-archive / patch render (line 275), so the bad number reaches the frontend on every card surface. The stats endpoint (`/api/v1/archives/stats`, line 940-988) has its OWN per-run accuracy loop with a tighter 50-200 % band filter shipped with #1593 — that's untouched and stays correct. **Fix** — `compute_time_accuracy(archive, run_aggregate=None)` gains an optional `run_aggregate` argument. When `run_aggregate["run_count"] > 1`, both `actual_time_seconds` and `time_accuracy` are returned as `None`. The frontend already falls through to `archive.print_time_seconds` for the Time display (`archive.actual_time_seconds || archive.print_time_seconds`) and conditionally renders the badge only when `archive.time_accuracy` is truthy, so multi-run archives now show "Estimated 5h 6m" with no badge instead of "Actual 1h 46m +188%". Single-run archives — the case the badge was designed for, and the only case where one-run actual versus whole-file estimate is a meaningful ratio — keep the original behaviour verbatim. **Audit-wide** — `archive_to_response` now passes `run_aggregate` through to `compute_time_accuracy` at the response-conversion call site. The 3 endpoints that did NOT previously load run aggregates (`backend/app/api/routes/archives.py` search endpoint's pre-FTS fast path at line 583 and FTS path at line 610, the single-archive PATCH endpoint at line 1419, and `backend/app/api/routes/projects.py::list_project_archives` at line 706) now batch-load `_load_run_aggregates` and pass it through, so the badge-suppression applies on every card surface — not just the main list and detail endpoints. One extra `SELECT … GROUP BY archive_id` per endpoint (the helper is already batched), cheap. Per the [[feedback_pr_reviews_thorough]] HARD RULE the fix is shipped across every call site that renders an archive card. **What this does NOT change** — the stats endpoint's per-run accuracy aggregation at line 940-988, its 50-200% band filter, the `archive.started_at` / `completed_at` source-of-truth for the latest-run timestamps, the frontend `ArchivesPage.tsx:1004-1022` rendering logic, or the time-accuracy computation for single-run archives. Reprint scope is unchanged (the reporter's option A — comparing summed run durations against the whole-file estimate — was not pursued because it produces a different but equally misleading number for the reprints-of-a-single-plate-file shape, where sum-of-runs = N × estimate). **Tests** — `backend/tests/unit/test_archive_run_aggregation.py::TestComputeTimeAccuracyMultiRun`: 4 new direct unit tests for the function — single-run archive keeps original badge, no `run_aggregate` argument keeps original badge (defends the legacy caller pattern), multi-run archive (reporter's exact 9-run case) clears both fields, and `run_count: 0` edge case keeps original behaviour. Two new integration tests against the live archives list endpoint: `test_archive_list_suppresses_time_accuracy_for_multi_run_archives` is the #1608 regression (3-plate plate-by-plate fixture, asserts both `actual_time_seconds is None` and `time_accuracy is None` AND that the estimate `print_time_seconds` survives so the card has something to render), and `test_archive_list_keeps_time_accuracy_for_single_run_archives` is the sanity check that the badge still shows for the happy path. Full backend pytest 3680 passed under `-n 30`; ruff clean. **No frontend change required** — the existing rendering logic in `ArchivesPage.tsx:1004-1026` (`formatDuration(archive.actual_time_seconds || archive.print_time_seconds || 0)` for the time + `{archive.time_accuracy && …}` conditional badge) naturally produces the desired "show estimate, no badge" presentation when the backend returns null for both fields. - **Queue / Review / Archive virtual-printer modes now complete the TLS handshake on hardened-distro hosts (#1610, reported by an AI-assisted diagnosis)** — Reporter ran Bambuddy in a container on a hardened-policy host (Fedora / RHEL with `update-crypto-policies` or similar) and OrcaSlicer / BambuStudio failed to connect to any non-proxy-mode virtual printer with `code=-1` after the TCP handshake completed. Switching the same VP to Proxy mode worked. They grepped the container and pinpointed that the #620 cipher-suite fix only patched `tcp_proxy.py::_create_client_ssl_context` (printer-facing) and missed every other slicer-facing VP TLS context. Diagnosis confirmed: real Bambu printers (and slicer paths written to mimic them) offer only the plain-RSA AES-GCM suites `AES256-GCM-SHA384` / `AES128-GCM-SHA256`; on stock OpenSSL these stay in `DEFAULT`, but a system crypto policy that strips them leaves the server side offering ECDHE-only and the slicer's ClientHello finds no overlap — handshake aborts before any data flows. **Audit-wide fix (4 contexts patched, one regression test class per site)**: (a) `backend/app/services/virtual_printer/bind_server.py::_create_tls_context` (port 3002, used by Queue/Review/Archive modes — the literal reported failure) — added `ctx.set_ciphers("DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256")`; (b) `backend/app/services/virtual_printer/mqtt_server.py::SimpleMQTTServer.start` (port 8883, slicer MQTT-over-TLS) — same cipher pin; (c) `backend/app/services/virtual_printer/tcp_proxy.py::TLSProxy._create_server_ssl_context` (slicer side of Proxy-mode 3002 — the #620 fix's *other* half that was never written) — same cipher pin; (d) `backend/app/services/virtual_printer/ftp_server.py::VirtualPrinterFTPServer.start` (port 990, FTPS upload) — changed from the historical `HIGH:!aNULL:!MD5:!RC4` to `HIGH:AES256-GCM-SHA384:AES128-GCM-SHA256:!aNULL:!MD5:!RC4`. The `HIGH` baseline is kept verbatim (not replaced with `DEFAULT`) so the cipher set stays a strict superset of what shipped before — the original `HIGH` set offers ~58 ciphers `DEFAULT` doesn't (CCM, ARIA, CAMELLIA, DSS variants); none are picked by any known Bambu slicer, but the [[feedback_dont_remove_compat_pinning]] HARD RULE says don't narrow a compat surface without proof. The `!aNULL:!MD5:!RC4` exclusions are preserved as well. For the three new contexts (a/b/c), the cipher string is `DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256` — verbatim match with the #620 client-side fix. **Verified strict-superset** at audit time (`{c['name'] for c in old.get_ciphers()}.issubset({c['name'] for c in new.get_ciphers()})` returns True for all four call sites). Per the [[feedback_vp_regression_matrix]] HARD RULE, the slicer-facing TLS surface gets the fix in one drop instead of a per-mode-per-issue ticket trail. Per [[feedback_dont_remove_compat_pinning]], the `minimum_version = TLSv1_2` and the FTPS `maximum_version = TLSv1_2` pins (the BambuStudio FTPS-data-channel-PSK-reuse compat) are unchanged — only the cipher list is widened, never narrowed. **Tests** (`backend/tests/unit/test_vp_tls_ciphers.py`, new file): 5 cases — one per slicer-facing surface (bind / mqtt / proxy-server / ftp) asserting `AES256-GCM-SHA384` AND `AES128-GCM-SHA256` are in the SSL context's offered cipher list, plus a guard test that the original #620 client-side `tcp_proxy._create_client_ssl_context` still has them so this audit's edits can't accidentally regress that fix. Cipher assertions use the production `CertificateService` to generate a real self-signed CA + per-VP cert pair into `tmp_path` (rather than mocking `load_cert_chain`) so the SSL contexts are configured exactly as production would. Full backend unit suite 3674 passed under `-n 30`; ruff clean. **Why neither maintainer nor the local CI box reproduces the bug**: stock OpenSSL 3.x ships `AES256-GCM-SHA384` / `AES128-GCM-SHA256` in `DEFAULT` already, so the missing `set_ciphers()` calls were harmless on most builds — verifiable with `python -c "import ssl; print(c['name'] for c in ssl.SSLContext().get_ciphers())"`. The bug only manifests on builds where a system crypto policy or vendor build flag narrows the default to forward-secrecy-only. The explicit cipher pins now survive any such narrowing. **What this does NOT do**: the printer-side TLS surface (proxy client context, MQTT printer-client context in `bambu_mqtt.py`) is unchanged — printers always sit behind the unfiltered #620 client pin, and there's no equivalent failure mode reported there. - **External-spool usage is now tracked when the AMS has empty slots in between loaded ones (#1607, reported by @ahmtcnby)** — Reporter had AMS slots 0–2 loaded, slot 3 empty, and an external spool. After every multi-filament print, the external's weight never decremented; assigning the external spool to the empty AMS slot 3 in Bambuddy made the deduction appear correctly. Root cause: when no explicit slot-to-tray mapping is available (path 5 of 6 in `usage_tracker.py::_track_from_3mf`, e.g. the very first print after a fresh container start before the request-topic subscription that captures `ams_mapping` from `print_command` is accepted — the bundle showed `Request topic subscription accepted. ams_mapping capture enabled` only fired at 19:36:29), the tracker falls back to a position-based mapping built from `spoolman_tracking.py::build_ams_tray_lookup`. That helper enumerated every AMS tray by `id` regardless of whether a spool was loaded, so the reporter's layout yielded `available_trays = [0, 1, 2, 3, 254]`. BambuStudio / OrcaSlicer compact their filament-assignment UI by hiding unloaded AMS slots — the slicer's 4th filament is the external, so the 3MF carries slot_ids 1–4 with slot 4 = external. Position-based mapping then routed slot 4 → `available_trays[3]` = AMS0-T3 (the empty slot) instead of 254 (the external). No spool assignment exists at AMS0-T3, so usage was silently skipped at line 1273-1274 and the external spool's weight stayed unchanged. **Fix** (`backend/app/services/usage_tracker.py:1232-1245`): the position-based fallback now filters `build_ams_tray_lookup`'s output to slots whose `tray_type` is non-empty before sorting. `build_ams_tray_lookup` itself is unchanged (its other callers — `spoolman_tracking.store_print_data`, `routes/printers`, `spool_assignment_notifications` — want every physical slot for AMS-state purposes); the filter is applied at the call site so we only narrow what the fallback uses. vt_tray entries are already filtered the same way inside `build_ams_tray_lookup` at line 174 (`if vt.get("tray_type"):`) — this mirrors that behaviour for the AMS side. **Why the reporter's workaround helped**: assigning the external spool to AMS0-T3 in Bambuddy made the wrong-target rewrite accidentally land on the right spool — the empty AMS slot resolved to the same spool the external was fed from. The fix removes the need for that workaround. **Tests**: 2 new in `backend/tests/unit/test_usage_tracker.py::TestPositionBasedFallbackEmptyAmsSlot` — `test_external_routed_correctly_when_ams_has_empty_middle_slot` is the literal #1607 regression (3 AMS slots loaded + 1 empty + external loaded, slicer's slot 4 must charge spool at AMS255-T0, **must NOT** charge anything at AMS0-T3 — explicit `(0, 3) not in handled_trays` assertion); `test_dense_ams_unchanged_no_empty_slots` is the no-empty-slots sanity check that confirms the fix doesn't regress the everyday case (4 AMS slots all loaded + external → slot 5 still maps to external). Path priority order unchanged: explicit `print_cmd` / MQTT / queue / color-match mappings still override the position-based fallback, so this only changes behaviour when none of those paths fired. Full backend pytest 3669 passed under `-n 30`; ruff clean. - **Custom maintenance type "documentation URL" now persists on create (#1596, reported by @BurntOutHylian — with the exact root cause pre-triaged in the issue body)** — POST `/api/v1/maintenance/types` hard-coded every field on the `MaintenanceType` constructor by name (`name`, `description`, `default_interval_hours`, `interval_type`, `icon`, `is_system`) and silently dropped `wiki_url`, even though the Pydantic schema accepted it and the response model echoed it back as `null`. PATCH was fine because it used `data.model_dump(exclude_unset=True) + setattr`, which is why editing a freshly-created type DID save the URL — masking the bug under any "save then immediately fix it" test. **Fix**: add `wiki_url=data.wiki_url` to the constructor call at `routes/maintenance.py:206`. **Frontend nit also addressed in the same drop** (#1596 nit section): `MaintenancePage.tsx:1131` `updateTypeMutation`'s inline `Partial<{...}>` shape listed `name | default_interval_hours | interval_type | icon` only. The value reached the API correctly at runtime because `api.updateMaintenanceType` accepts `Partial` (which includes `wiki_url`), but the local type was misleading — anyone reading the mutation would wrongly conclude `wiki_url` wasn't part of the update payload. Extended the inline shape to include `wiki_url?: string | null`. **Tests**: one new integration test in `test_maintenance_api.py::test_create_custom_type_persists_wiki_url` — POSTs a custom type with a `wiki_url`, asserts the POST response carries it, and verifies via a separate GET round-trip that the value actually committed (defending against the "response echoes request body" failure mode the bug would have masked). Full 5565-test backend suite green; ruff clean; frontend build clean; ESLint zero output; touched MaintenancePage vitest green. - **External-folder `.gcode.3mf` files now show thumbnails, and every ingest path stores the same canonical `file_type` for sliced outputs (#1600, reported by @maziggy)** — Reporter noticed external-folder sliced outputs landed with no thumbnail. Cause: four backend ingest paths classified `LibraryFile.file_type` differently for the same `.gcode.3mf` family. The upload, ZIP-extract, and in-process paths used `os.path.splitext(filename)[1]` which returns `.3mf` for `foo.gcode.3mf`, stored `file_type="3mf"`, and matched the thumbnail-extraction gate at `library.py:1467` (`if file_type == "3mf":`). The external-folder scan path explicitly detected the compound and set `file_type="gcode.3mf"` — preserving the "sliced output" identity — but then skipped both `if file_type == "3mf":` (mismatch) and `if file_type == "gcode":` (also mismatch), so the file landed with `thumbnail_path = None`. Same compound-extension drift that bit #1543's 3D preview gates, just in a different surface that the #1543 frontend audit didn't trace back to. **Unified fix** (per the user's "unify if it's safe" directive): new `classify_file_type(filename)` helper in `library.py` is now the single source of truth — returns `gcode.3mf` for sliced outputs and `ext[1:]` otherwise. Applied to every ingest path: upload (`routes/library.py:1704`), ZIP-extract (`routes/library.py:1998`), external-folder scan (the bug site, plus the manual compound check is replaced), and the in-process `save_3mf_from_bytes()` helper (`routes/library.py:471` — used by MakerWorld import). The external-scan thumbnail gate is widened to `if file_type in ("3mf", "gcode.3mf"):` so a sliced output now goes through ThreeMFParser (a `.gcode.3mf` IS a 3MF zip with `Metadata/plate_1.png` thumbnail; the parser doesn't care about the trailing extension). The gcode-download endpoint at `GET /api/v1/library/files/{id}/gcode` (`routes/library.py:4390`) had the same drift in reverse — its gate was `elif file.file_type == "3mf":` so a row stored with `file_type="gcode.3mf"` (the external-scan path's pre-unification behaviour, and now the canonical going forward) was rejected with HTTP 400. Widened to `elif file.file_type in ("3mf", "gcode.3mf"):` so both ingest histories work. **One-shot DB migration** in `backend/app/core/database.py::run_migrations` backfills existing legacy rows: `UPDATE library_files SET file_type='gcode.3mf' WHERE file_type='3mf' AND LOWER(filename) LIKE '%.gcode.3mf'`. Idempotent (post-update rows no longer match the `file_type='3mf'` predicate, so re-runs at every boot are no-ops) and dialect-neutral (`LOWER` + `LIKE` are identical under SQLite and Postgres per the [[feedback_sqlite_and_postgres_upfront]] HARD RULE; behaviour-identical on Postgres by construction, tested explicitly on SQLite in the new regression suite). Without the backfill, users would have a permanent split state in the DB — old uploads at `3mf`, new uploads at `gcode.3mf` — which would (a) double-bucket sliced outputs in the dashboard stats query at `routes/library.py:4615` (`SELECT file_type, count(*) GROUP BY file_type`) and (b) show two entries in the file-manager filter dropdown for the same conceptual type. **Frontend untouched** — `FileManagerPage.tsx` and `ProjectDetailPage.tsx` already accept both `'3mf'` and `'gcode.3mf'` for Preview-3D, type-pill colour, and the file action gate per the #1543 fix. After the migration the DB only contains canonical values, so the legacy `'3mf'` branches in the frontend become dead code for sliced files — they stay in place to handle any future ingest path I missed (defence in depth — better a redundant gate than an empty card). **Tests**: 13 new in `test_library_classify_file_type.py` covering the helper across every compound / casing / no-extension case; 3 new in `test_library_file_type_backfill_migration.py` (legacy `.gcode.3mf`/`3mf` row backfilled, mixed-case filenames upgraded via `LOWER()`, unrelated `.bak`-suffixed compound substring left untouched, plain `.3mf` / raw `.gcode` / `.stl` untouched, idempotent on re-run); 2 new integration tests in `test_library_api.py` (upload of `.gcode.3mf` now stores `file_type="gcode.3mf"` via the unified path; the gcode-download endpoint accepts a row with `file_type="gcode.3mf"` and returns the embedded gcode). Full backend pytest 5564 passed under `-n 30`; ruff clean; frontend build clean; eslint zero output; i18n parity green at 5007 leaves × 9 locales. - **Virtual-printer "Send file" IP rewrite now also fires for VPs without a dedicated bind IP (#1429 follow-up, residual case confirmed by @Mape6 on the 2026-06-02 daily)** — The first #1429 fix's `_refresh_ip_encoding` early-returned when `mqtt_server.bind_address` was `0.0.0.0` or empty (which is the default for any VP created without a bind IP selected — covered by the "Deferred (fix D)" note in the original #1429 changelog entry). On a flat-LAN install that's the typical case, so for those VPs the encoding never armed, `_rewrite_net_info_ips` was a no-op on every push, and the slicer kept following the real-printer IP to the printer's SD card — the exact symptom @Mape6 reported after pulling the 2026-06-02 daily that supposedly fixed this. **Fix (`backend/app/services/virtual_printer/mqtt_bridge.py`)**: new `_resolve_host_interface_for_target()` helper consults the existing `network_utils.find_interface_for_ip()` to pick the host interface in the same subnet as the printer's IP when `bind_address` is unspecified. `_refresh_ip_encoding` now falls back to that auto-resolved IP instead of returning early; an explicit bind IP still takes precedence. INFO log line distinguishes the two paths (`armed: ... (bind_address)` vs `armed: ... (auto-resolved)`) so support bundles answer "which IP did the rewrite pick?" without re-reasoning. If no interface matches the printer's subnet (the helper returns None), the bridge leaves encoding unarmed and the cache flows through as before — no crash, no wrong rewrite. **Tests** — 4 new in `backend/tests/unit/test_vp_mqtt_bridge.py::TestBindAddressAutoResolve`: rewrite arms via auto-resolved IP when bind_address is `0.0.0.0`; rewrite stays disabled when no host interface matches (no crash); explicit bind_ip takes precedence over auto-resolve; helper itself returns None when `find_interface_for_ip` does. All 39 mqtt_bridge tests pass; full backend unit suite (3667 tests) green; ruff clean. **Note on subnet matching**: the helper is best-effort — it picks the interface whose subnet contains the printer's IP, which is the right answer when slicer + printer + Bambuddy share a LAN (the typical home-lab case). Setups where the slicer reaches Bambuddy via a different interface than Bambuddy uses to reach the printer (multi-homed hosts, Tailscale + LAN where the slicer is on Tailscale and the printer on LAN) may still need an explicit bind IP — there's no leak in that case, just a rewritten value the slicer can't route to. The full audit-shaped resolution (enumerate accepted connections, per-slicer rewrite) is still a separate change. - **Virtual-printer "Send file" no longer redirects from Bambuddy to the physical printer's SD card once the printer powers on, and the mode button labels finally match the wire values stored in the DB (#1429, reported by @TrickShotMLG02, confirmed by @Mape6)** — Two reporters on completely different network topologies (3-subnet routed via OPNsense vs. flat single-LAN) saw the same symptom: with the physical printer off and Bambuddy freshly restarted, the slicer's "Send" landed in Bambuddy's archive; once the printer powered on, every subsequent "Send" went straight to the printer's SD card and bypassed Bambuddy entirely. @Mape6's packet capture on the flat-LAN case ruled out subnet / mDNS-reflector / firewall theories — the slicer just had a non-Bambuddy IP for the FTP destination once the printer was online. Bundle analysis: `mape6-before` (printer off) showed clean FTP receive + archive lines; `mape6-after` (printer on) had zero FTP connection attempts to Bambuddy, full stop. The mode-label discrepancy in every support bundle was a separate red herring that needed clearing up in the same drop. **Root cause** — `backend/app/services/virtual_printer/mqtt_bridge.py::_on_printer_raw` caches the real printer's `push_status` and rewrites `net.info[*].ip` from real-printer LE-uint32 to VP-bind-IP LE-uint32 so the slicer's FTP destination resolves to the VP. The rewrite has been in tree since 2026-05-03 and the unit test that ships with it passes. But the encoding (`_target_ip_uint32_le`, `_vp_ip_uint32_le`) was only computed inside `_resolve_client` on **client-identity change**, and `_resolve_client` early-returned (`if current is self._target_client: return`) on every refresh tick when the same client object was still bound. So if the printer's MQTT client object existed but `ip_address` was empty/stale at first bind (e.g. the printer's DB row hadn't picked up its discovered IP yet, or the client was constructed before the SSDP refresh), the encoded LE-uint32 stayed `None`, the rewrite block was skipped, the cache filled with the real printer IP, the sticky-keys preservation in the same function kept that poisoned `net` value alive across every subsequent incremental push, and the slicer followed the leaked IP to the real printer. The only way to clear it was to restart Bambuddy with the printer off — which is exactly the workaround both reporters independently arrived at. **Same shape on multi-NIC printers**: the rewrite only matched entries whose `ip` equalled `_target_ip_uint32_le`, so an X1C / H2D Pro reporting two active interfaces (WiFi + Ethernet) would have one entry rewritten and the other leaking the printer's other IP — a separate FTP fallback path that bypasses the VP even when the primary rewrite worked. **Fixes (mqtt_bridge.py)**: (1) `_resolve_client` now calls a new `_refresh_ip_encoding()` helper on every refresh tick, even when the client identity is unchanged — re-reads `current.ip_address`, re-encodes if either side changed, self-heals once `ip_address` becomes valid. (2) When the encoding becomes valid for the first time *after* the cache has already been populated, `_refresh_ip_encoding()` sweeps the cached `_latest_print_state` via the new `_rewrite_net_info_ips()` helper so the slicer's next pull sees the rewritten value — without this, sticky-key preservation keeps the poisoned cache alive across every incremental update. (3) `_rewrite_net_info_ips()` rewrites **every** non-zero `net.info[].ip` entry that doesn't already equal the VP bind IP, not only entries matching `_target_ip_uint32_le` — defensive against multi-NIC printers, against `_target_ip_uint32_le` being stale, and against unknown secondary interfaces leaking. Zero-IP entries (placeholders for unpopulated interfaces) are deliberately left alone so the slicer's "active interface" detection still recognises them as absent. (4) The rewrite path now logs at INFO when encoding arms or updates and at INFO when the cache sweep rewrites entries, so future support bundles directly answer "did the rewrite fire?" without re-reasoning about timing. **Mode wire-value rename (#1429 follow-up, separate confusion source)** — The UI button labeled "Archive" had always saved the wire value `immediate`, and "Queue" had always saved `print_queue`. Both reporters' support bundles showed `mode: immediate` while the UI said "Archive", and @TrickShotMLG02 specifically asked "I have no idea why it says immediate in the support-info.json file. In the webui the printer is set to archive". The mismatch was load-bearing for the debug session and had to be cleared up. Canonical wire values are now `archive` / `review` / `queue` / `proxy` matching the button labels 1:1. **Backend rename**: new `backend/app/models/virtual_printer.py::VP_MODE_*` constants + `normalize_vp_mode()` helper accepts legacy `immediate` / `print_queue` and translates to canonical. `VirtualPrinter.mode` default flipped to `archive`. `backend/app/services/virtual_printer/manager.py::VirtualPrinterInstance.__init__` normalises on construction so a legacy DB row read before the migration window has finished still dispatches to the correct handler; `on_file_received`, `on_print_command`, and `sync_from_db`'s change-detection all consume canonical values via `normalize_vp_mode()`. `backend/app/api/routes/virtual_printers.py::create_virtual_printer` and `update_virtual_printer` accept both forms on input and normalise to canonical before storage; `backend/app/api/routes/settings.py::get_virtual_printer_settings` normalises on read so frontend mode-button highlighting works for legacy stored values; `update_virtual_printer_settings` accepts and normalises on write. `backend/app/schemas/settings.py::AppSettings.virtual_printer_mode` default flipped to `archive` with updated description. **One-shot DB migration**: `backend/app/core/database.py::run_migrations` rewrites every `virtual_printers.mode` and `settings.virtual_printer_mode` row from `immediate` → `archive` and `print_queue` → `queue`. Idempotent — re-running on canonical values is a no-op, important because the full migration set runs every boot. Identical statement under SQLite and Postgres (plain `UPDATE ... WHERE` on a string column, no dialect-specific syntax) per the [[feedback_sqlite_and_postgres_upfront]] HARD RULE; tested explicitly on SQLite in the new regression suite, behaviour-identical on Postgres by construction. The historical single-VP migration (legacy `settings` rows → `virtual_printers` table on first multi-VP boot) gets the same `immediate` → `archive` / `print_queue` → `queue` translation; the historical `queue` → `review` alias is preserved because it predates the rename and reflected the user's intent at the time (the old wire `queue` meant "pending review", not "add to print queue"). **Frontend rename**: `VirtualPrinterSettings.tsx`, `VirtualPrinterCard.tsx`, and `VirtualPrinterAddDialog.tsx` all switched their button click handlers and `LocalMode`/`Mode` type aliases from `'immediate' | 'review' | 'print_queue' | 'proxy'` to `'archive' | 'review' | 'queue' | 'proxy'`. Each file gained its own `normalizeMode()` helper that translates legacy values arriving via stale-cached settings payloads to canonical, so the right mode button lights up even when the backend migration hasn't completed for that user's session yet. The two `printer.mode === 'queue' ? 'review' : printer.mode` legacy mappings in `VirtualPrinterCard.tsx::useEffect` and the error-recovery path have been replaced with `normalizeMode()` — they were the source of the test failure I caught mid-implementation where `mode: 'queue'` (the new canonical for the Queue button) was being incorrectly aliased back to `'review'` and hiding the auto-dispatch + force-color-match toggles. `frontend/src/api/client.ts::VirtualPrinterMode` is now the union of both canonical and legacy values (`'archive' | 'review' | 'queue' | 'proxy' | 'immediate' | 'print_queue'`) so older API clients (forks, mobile shortcuts, scripted setups) typecheck; the `updateSettings` body type narrows to canonical-only to steer new code. **Mode handler is NOT the dispatch bug**: `manager.py::_archive_file` is the handler for `archive` mode and it does archive-only (no dispatch to the physical printer). The user-visible "files end up on the printer's SD card" symptom was the IP-leak from the bridge cache, not a mode-dispatch bug. The mode rename is purely a clarity / support-bundle-accuracy fix. **Tests** — `backend/tests/unit/test_vp_mqtt_bridge.py`: 2 new in the bridge-rewrite class — `test_net_info_ip_rewritten_for_unknown_secondary_interface` covers the multi-NIC X1C / H2D Pro case where the printer reports an interface IP Bambuddy never saw; both entries get rewritten, the placeholder zero entry stays untouched. `test_late_arriving_printer_ip_rewrites_existing_cache` is the primary #1429 regression — bridge binds to a client with `ip_address=""`, first push lands and poisons the cache with the real-printer IP (the pre-fix state), the printer's `ip_address` then becomes known, the next `_resolve_client` tick arms the encoding AND sweeps the cached `net.info[].ip` so the slicer's next pull sees the VP IP. Without the sweep, sticky-key preservation would keep the poisoned value alive forever. `backend/tests/unit/test_vp_mode_rename_migration.py`: new file, 3 tests — legacy `immediate` → `archive` and `print_queue` → `queue` rewrites under SQLite, canonical values pass through untouched; legacy `virtual_printer_mode` setting also gets rewritten; running the migration twice is idempotent (every boot re-runs the full migration set). `backend/tests/integration/test_virtual_printer_api.py`: 3 reworked tests cover input-side normalisation — `test_update_mode_to_queue` asserts canonical, `test_update_mode_legacy_print_queue_normalises_to_queue` and `test_update_mode_legacy_immediate_normalises_to_archive` assert legacy → canonical translation on storage. The pre-existing `test_update_mode_legacy_queue_maps_to_review` (predating the rename, asserted the old `queue` → `review` alias) is removed; the new `test_update_mode_to_archive` covers canonical archive setting. **All other VP tests were updated to canonical** — `test_virtual_printer.py` (43 occurrences), `test_vp_diagnostic.py` (1), `test_virtual_printer_api.py` mocks (5) renamed; the `sync_from_db_restarts_on_mode_change` test had to be repaired by hand because the sed pass made both sides `archive` (defeating the change detection); now uses `archive` → `review` to actually exercise the change branch. Frontend: `VirtualPrinterCard.test.tsx`, `VirtualPrinterSettings.test.tsx`, `VirtualPrinterDiagnosticModal.test.tsx` updated to canonical fixtures and assertions; the legacy `queue maps to review` test in `VirtualPrinterSettings.test.tsx` replaced with two tests — legacy `immediate` lights up the Archive button, legacy `print_queue` lights up the Queue button, both via the new client-side `normalizeMode()` helper. The five `InventoryPage*.test.tsx` files that hardcoded `virtual_printer_mode: 'immediate'` in their settings mocks bulk-renamed to `'archive'`. **CI gates green**: backend pytest 5546 passed in 73.88s + 7.10s under `-n 30` / `-n 12` parallel; ruff clean; frontend `npm run build` clean (TypeScript + Vite); ESLint zero output; vitest 2045 passed in 26.12s; i18n parity script clean at 5007 leaves × 9 locales. **Deferred (fix D in the diagnosis writeup)**: bind_ip == 0.0.0.0 path. The rewrite is still explicitly skipped when bind_ip is the unspecified address, which is correct for the routing (you can't tell a slicer to FTP to 0.0.0.0) but leaves users without a dedicated bind IP exposed to the same IP-leak pattern. Both reporters had `has_bind_ip=true` in their bundles so this isn't load-bearing for #1429 itself; will be addressed as a separate audit-shaped change that needs to enumerate the host's outbound IPs and pick the one that can reach the printer, with its own test surface. **Out of scope for this PR**: port 40024 in @Mape6's packet capture (Bambu Network Plugin's LAN-Send pre-flight port) — a probe that arrives at the VP IP, finds no listener, and the slicer falls back. Adding a 40024 listener is conceptually a different surface (handshake parsing, not MQTT cache state) and the cache-leak fix alone removes the underlying redirection so the 40024 probe lands on a VP that's actually the right destination. Will reassess if either reporter still sees mis-routing after this fix. - **Multi-plate `.gcode.3mf` archives + reprints no longer under-report filament, time, and cost — project stats and parser both fixed (#1593, reported by @needo37)** — Reporter printed 3 plates of a multi-plate file: Archive Print Log correctly recorded 3 completed runs at distinct durations and filament weights; Project page showed `Print Jobs: 1 / 1 parts printed`, plate-1's `1h53m / 58g / $1.09`; Archive card said `3 prints` but rendered plate-1's `57.6g / 1h45m / 1 object`. Two distinct causes stacked. **Root cause 1 — 3MF parser only read the first plate**: `ThreeMFParser._parse_slice_info` (`backend/app/services/archive.py:191`) called `root.find(".//plate")` and pulled `prediction` / `weight` from that one element — so for any multi-plate file the archive's file-level `print_time_seconds` / `filament_used_grams` reflected plate 1 alone. The per-plate `/plates` endpoint already looped `findall(".//plate")` and was correct, which is why the plate carousel showed the right numbers while the archive card was wrong. **Root cause 2 — project rollup aggregated `PrintArchive`, not the per-run log**: `compute_project_stats` and the `list_projects` quick-stats block (`backend/app/api/routes/projects.py`) summed `PrintArchive.print_time_seconds / filament_used_grams / cost / energy_*` `WHERE project_id = X`. A reprint reuses the source archive row and only adds a new `PrintLogEntry`, so 3 sequential runs of one file collapsed to 1 archive — and that archive's numbers were already plate-1-only because of root cause 1. The Archive Print Log path was correct because it already drove off `print_log_entries` (`archives.py:420` — *"Reads from print_log_entries so reprints contribute each run"*); project stats just hadn't been pointed at the same source. **Parser fix**: `_parse_slice_info` now loops `findall(".//plate")` and sums `prediction` → `print_time_seconds` and `weight` → `filament_used_grams` across all plates. Per-plate concepts (`plate_number`, `_plate_index`, `printable_objects`) are only set when there's exactly one plate — for multi-plate exports the archive represents all plates and a single plate index is meaningless at the file level. `bed_type` keeps the first plate's value as a best-effort archive default. Malformed `prediction` / `weight` values on individual plates skip cleanly rather than poison the sum. **Stats fix**: `compute_project_stats` and the `list_projects` quick-stats block both switch to an inner join `print_log_entries → print_archives` `WHERE archives.project_id = X`. `total_archives` becomes `COUNT(PrintLogEntry.id)` (actual runs, not files); `failed_prints` becomes the count of runs in `failed/aborted/cancelled/stopped`; `completed_items` becomes `SUM(PrintArchive.quantity)` filtered to runs with `status='completed'` (each run contributes its archive's quantity); `total_print_time_hours / total_filament_grams / estimated_cost / total_energy_*` come from `PrintLogEntry` columns. Orphan log rows (`archive_id IS NULL` after archive deletion via `ON DELETE SET NULL`) are excluded by the inner join — they can't be attributed to any project. **Backfill behaviour** (intentional, matches the reporter's "forward-only" note): users with AMS spool tracking — the reporter's case — have per-run `PrintLogEntry.filament_used_grams` from the tracked spool delta, not the plate-1 estimate, so project stats become correct *immediately* after the rollup fix with no reslice required. Users without tracking fall back to the archive estimate; their stats undercount until they reprint with the fixed parser. The Archive **card** still reads `PrintArchive.filament_used_grams` directly, so old archives keep their plate-1-only numbers until a reslice/rescan repopulates `file_metadata`. **Same-shape fix carried forward**: `system.py::system_info` (the System Info page's lifetime totals) summed `PrintArchive.print_time_seconds` / `filament_used_grams` with the identical bug — reprints collapsed to one archive, multi-plate files reported plate-1-only. The route now sums from `PrintLogEntry.duration_seconds` / `filament_used_grams` like the project rollup, so every run contributes its measured per-run actual. **Same-shape fix in the time-accuracy metric** (`archives.py::get_archive_stats`): the metric computed `estimate / actual` per run where `estimate = PrintArchive.print_time_seconds`. Post-parser-fix multi-plate archives have file-level estimate but per-run actual = one plate's duration → ratio ≈ N×100% for an N-plate file (300% for the reporter's 3-plate case), which would drag the printer-level average to noise. The calc now clamps each row to the [50%, 200%] plausibility band before contributing to the average; single-plate accuracy is fully included (the case the metric is designed for), multi-plate plate-by-plate runs and one-off outliers (manual intervention, purge waste blowing the estimate) are excluded. **Tests**: 4 new in `test_archive_service.py::TestMultiPlateSliceInfoSum` — three-plate file sums prediction + weight (the reporter's exact numerics: 7140+6000+6300 → 19440s, 19.2+20.0+18.8 → 58.0g); single-plate path preserves `plate_number` + objects + bed_type; multi-plate ignores per-plate object lists; malformed per-plate values are skipped without poisoning the sum. 4 new in `test_projects_api.py::TestProjectStatsPerRun` — 3 reprints show as 3 jobs with summed totals (matches the reporter's exact 3-run scenario); orphan log entries don't bleed into any project; mixed-outcome archive splits cleanly between `completed_prints` (quantity-weighted) and `failed_prints` (run-counted); list-view quick stats agree with per-project stats. 1 new in `test_archive_run_aggregation.py` — the accuracy band filter excludes multi-plate plate-by-plate runs (estimate 18000s / actual 6000s = 300%) so a single-plate file's near-100% reading stays the printer's average. Two pre-existing assertions updated to reflect the corrected semantics: `archive_count` and `total_archives` now count runs, so files attached but never printed (status `"archived"`) contribute 0 — that's the right answer, not a regression. Full backend suite + ruff clean. - **Webhook printer-status / stop / cancel routes 500'd on every connected printer because the route treated the PrinterState dataclass as a dict (#1584, reported via in-app bug report)** — Reporter saw `GET /api/v1/webhook/printer/{id}/status` return `500 Internal Server Error` with a valid API key carrying the `read_status` scope, while `GET /api/v1/system/info` returned 200 with the same key — so auth and routing were fine, the handler itself was crashing. Cause: `printer_manager.get_status(printer_id)` returns a `PrinterState` dataclass (`backend/app/services/bambu_mqtt.py`), not a dict. The route at `webhook.py:266-270` called `status.get("connected", False)`, `status.get("state")`, `status.get("current_print")`, `status.get("progress")`, `status.get("remaining_time")` — every one raised `AttributeError`, which Starlette surfaced as a generic 500. Reporter's id-1 (printer exists) returned 500; non-existent ids returned 404 — exactly because the early `Printer not found` branch fired before reaching the crash. Same shape in two adjacent routes: `webhook_stop_print` (`POST /printer/{id}/stop`) and `webhook_cancel_print` (`POST /printer/{id}/cancel`) checked `status.get("connected")` / `status.get("state")` for their precondition gates. 8 crash sites total across the three routes. **Fix**: every `status.get("X", default)` replaced with attribute access (`status.X if status else default`); Pydantic response schema unchanged. `PrinterState`'s dataclass defaults cleanly cover the `status is None` branch (printer registered but never connected — the route now returns 200 with `connected=false, state=null, …` rather than crashing). **Tests** (`backend/tests/integration/test_webhook_printer_status.py`): 7 new — status route returns 200 with the dataclass attributes mapped into the response (regression for the exact #1584 shape); status route returns 200 with sensible defaults when `get_status()` returns None; status route returns 404 for a non-existent printer (control case proving the auth path is unaffected); stop route returns 503 when disconnected (pre-fix would have 500'd here); stop route returns 409 when state is not `RUNNING`; cancel route returns 503 when disconnected; cancel route returns 409 when state is not `RUNNING`/`PAUSE`. Runtime-verified end-to-end against a live PG-backed instance before and after: same key + same printer id, 500 before the patch and 200 with the correct payload after. Full backend suite + ruff clean. - **Path-traversal CI backstop now recognises markers on the closing-paren line (project-wide convention)** — `test_no_unsafe_path_joins.py::test_route_path_arithmetic_is_safe_joined_or_marked` AST-walks every Path-arithmetic site in `api/routes/` + `services/` and demands either `safe_join_under(...)` or a `# SEC-PATH-OK: ` marker. The marker-detection helper only scanned the BinOp's own line range (`lineno..end_lineno`), but the project's convention puts the marker on the line of the wrapping closing paren — one past `end_lineno`. The backstop flagged 30 already-marked, already-safe sites as findings, masking the fact that the post-GHSA marker work is complete. The helper now peeks one line past `end_lineno` IF that line begins with a continuation token (`)`, `]`, `}`, `,`), capturing exactly this convention without giving a free pass to a marker on a wholly unrelated next statement. 5 new tests in `TestMarkerDetection` pin the contract: marker on the BinOp line recognised; marker on the closing-paren line recognised; an unrelated marker on a later statement does NOT silence; a marker on a non-continuation line right after the BinOp does NOT silence; no marker anywhere is still flagged. Integration test now passes against the existing tree — 30 findings → 0 — with no changes to any guard / sanitisation in routes or services. - **Deleted local profiles no longer linger in the SliceModal preset dropdown; new manual "Refresh" button surfaces cloud-side deletions without waiting for the 5-minute cache (#1581, reported by @lloydjohnson)** — Reporter saw deleted local AND cloud profiles still appearing in the slice menu after removing them. Two distinct causes wired together. **Local half (real bug)**: `LocalProfilesView`'s import and delete mutations invalidated `['localPresets']` (the Local Profiles management view's own query) but not `['slicerPresets']` — the SliceModal reads from the unified `/slicer/presets` endpoint via a separate React Query key (`SliceModal.tsx:425`, `staleTime: 60_000`), so a freshly-deleted preset kept rendering in the dropdown until the modal's 60 s staleTime elapsed plus a refocus / remount. The backend was correct end-to-end (`delete_local_preset` removes the DB row, `get_db()` auto-commits, `_fetch_local_presets` reads fresh from DB with no backend cache). Both mutations now also invalidate `['slicerPresets']` so the next modal open shows the current set. **Cloud half (by-design backend cache + new opt-in bypass)**: `_fetch_cloud_presets` keeps a 5-minute per-(user, token) in-process cache balancing "users see their freshly-saved presets quickly" against "a busy install doesn't hit Bambu Cloud once per modal open" (`slicer_presets.py:69`). The user deletes cloud presets in Bambu Studio / Bambu Handy, not in Bambuddy, so there's no event hook to invalidate on — the cache only refreshes when the TTL expires. Rather than shorten the TTL (which would effectively rate-limit the cloud for every user), the listing endpoint gains an opt-in `?refresh=true` query param that bypasses BOTH the cloud cache and the 1-hour bundled-preset cache for that one call; the fresh result is still written back so subsequent normal callers still hit cached responses. **New SliceModal "Refresh" button**: lives in the preset section header next to the cloud-status banner, calls `getSlicerPresets({refresh: true})` and writes the fresh slots into the `['slicerPresets']` cache via `queryClient.setQueryData` (so the spinner disappears immediately rather than triggering a second refetch). Spins the `RefreshCw` icon while in-flight; disabled during a slice enqueue so users can't fire it twice. **i18n**: real translations for `slice.refreshPresets` + `slice.refreshPresetsTitle` (action label + tooltip) across all 9 locales per the [[feedback_translate_dont_fallback]] HARD RULE; parity script green at 5007 leaves × 9 locales. **Tests**: 2 new backend in `test_slicer_presets.py` (`refresh=True` re-hits Bambu Cloud even with a warm cache + still writes the fresh result back for the next normal call; same shape for `_fetch_bundled_presets`); 1 new frontend in `LocalProfilesView.test.tsx` asserts the delete flow invalidates `['slicerPresets']` in addition to `['localPresets']` via a spied QueryClient. Full backend suite + frontend vitest + ruff + eslint + i18n parity green. - **STL thumbnail noise on first generation: matplotlib cache + font_manager scan (reported by @maziggy)** — On first STL upload, three matplotlib-internal log lines surfaced: `WARNING [matplotlib] /opt/claude/.config/matplotlib is not a writable directory` (Bambuddy's `$HOME` isn't writable for the default config path so matplotlib fell back to `/tmp/matplotlib-XXXXXX`), `INFO [matplotlib.font_manager] Failed to extract font properties from NotoColorEmoji.ttf` (matplotlib doesn't support the COLR/COLR1 emoji format; this is per-font), and `INFO [matplotlib.font_manager] generated new fontManager` (the cache was rebuilt). Because the fallback was `/tmp`, every host reboot lost the cache and the font scan ran again. **Fix is in `stl_thumbnail.py` before the matplotlib import**: (a) `_configure_matplotlib_cache()` sets `MPLCONFIGDIR` to `settings.base_dir / .cache / matplotlib` (mkdir'd if missing) so the cache persists across container restarts and the writable-dir warning never fires; respects an externally-set value so operators who chose their own path aren't overridden; best-effort with a debug fallback if settings can't be imported or the mkdir fails. (b) `logging.getLogger("matplotlib.font_manager").setLevel(WARNING)` at module import demotes the per-font INFO scan so the first cold start (before the cache is populated) doesn't surface a multi-line matplotlib preamble. **Tests**: 3 new in `test_stl_thumbnail.py` — the font_manager logger is at WARNING after module import; `_configure_matplotlib_cache` creates the directory under `base_dir` and sets `MPLCONFIGDIR` to point at it; an externally-set `MPLCONFIGDIR` is preserved verbatim. - **Bulk-upload ZIPs of stub / empty STL files no longer spam the log with thousands of warnings (reported by @maziggy)** — Uploading a ZIP containing many minimal STL stubs (e.g. the 24-byte `solid test\nendsolid test` shape) emitted one `WARNING [backend.app.services.stl_thumbnail] Failed to load STL or empty mesh: ` per file. The warnings were technically correct — `trimesh.load(...)` returned a valid Mesh with zero vertices, the safeguard at `stl_thumbnail.py:54` matched, and the function returned None so the library entry got created without a thumbnail — but the volume turned a successful ZIP upload into a journal full of WARNING lines. **Two-step fix**: (1) the per-file message at `stl_thumbnail.py:55` demoted from `logger.warning` to `logger.debug`; this is a per-file content observation, not an actionable error, and the caller already handles None correctly. The branch now catches only the rare "large enough but trimesh still can't parse it" case, still visible in debug logs without spamming production. (2) New module constant `MIN_USABLE_STL_BYTES = 200` (binary STL with one triangle = 80B header + 4B count + 50B triangle = 134B; ASCII STL with one triangle ≈ 150B; 200 is a safe floor below any real STL). Three thumbnail call sites in `library.py` (extract_zip_file ZIP entry path, single-file upload, `_backfill_external_stl_thumbnails`) pre-skip files below this size BEFORE calling `generate_stl_thumbnail`, so stubs / placeholders / corrupted files never enter the trimesh pipeline at all. **What this does NOT change**: behaviour is identical for any real STL — generation still runs, MAX_VERTICES still triggers simplification at 100k vertices for the 256×256 thumbnail render, large files still get thumbnails. **Tests**: 2 new in `test_stl_thumbnail.py` — one verifies `MIN_USABLE_STL_BYTES` sits above the smallest binary (134B), the smallest ASCII (150B), and the reporter's 24-byte stub case; the other writes the verbatim 24-byte stub from the bug report, calls `generate_stl_thumbnail`, and asserts no `WARNING`-level "empty mesh" record appears in `caplog`. Full backend suite green; ruff clean. - **Bambu Cloud sign-in failures caused by an upstream Cloudflare challenge now surface an actionable message instead of "Invalid response from Bambu Cloud" (#1575, reported by @cliveflint)** — Reporter hit "Invalid response from bambulabs when trying to sign in with authenticator pass code" on a Pi (UK network). Log showed three back-to-back `POST /api/sign-in/tfa` calls all returning Cloudflare's "Just a moment..." HTML interstitial instead of JSON; `backend/app/services/bambu_cloud.py::verify_totp` caught the `json.JSONDecodeError` and returned the opaque "Invalid response from Bambu Cloud" message. **Root cause is Cloudflare-side, not Bambuddy**: a curl from this machine with the same honest `Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)` UA at 2026-06-02 returned a clean `HTTP/2 400 {"code":5,"error":"Login failed"}` JSON — same UA, same headers, different network. CF's bot management appears to flag conditions (per-IP / TLS-fingerprint / rate / transient mitigation window) that don't reproduce from us. No reliable way to *prevent* the challenge from our side without browser impersonation, which is explicitly off the table per the 2026-05-12 compliance audit. **Fix is diagnostic, not bypass**: new `_detect_cloudflare_challenge(response) -> str | None` helper inspects the failed-parse response for CF markers (`"Just a moment..."` in body, `"challenges.cloudflare.com"` in body, HTTP 403 with `cf-mitigated` header, HTTP 503 with `cf-ray` header) and returns a message that attributes the block to Bambu Lab's Cloudflare protection, suggests waiting a few minutes, and tells the user that signing in to bambulab.com from a browser on the same network usually clears the challenge. Wired into all three JSON-parse sites: `login_request`, `verify_code`, and `verify_totp` — previously only `verify_totp` had a defensive catch; `login_request` and `verify_code` let the parse error bubble to `BambuCloudAuthError` with `"Expecting value..."` as the detail, which surfaced as a generic 401 in the UI. **Tests**: 8 new in `TestCloudflareChallengeDetection` (`backend/tests/unit/services/test_bambu_cloud.py`) — direct helper tests for each of the four CF markers, a negative case (real JSON 400 with `cf-ray` header from the actual successful curl response above is NOT misclassified as a challenge so the application-level "Login failed" still surfaces), an attribution check (message must name "Cloudflare" and "bambulab.com" so users can act on it), and full-stack tests covering all three call sites with the verbatim interstitial fragment from the reporter's log. The existing `test_verify_totp_cloudflare_blocked` updated to assert the new actionable message. Full 5486-test backend suite green; backend ruff clean. - **OIDC auto-provisioning now reads the standard `email` claim for `User.email` when `Email Claim` is set to a non-email identity claim (#1569, reported by @anderl1969)** — Reporter configured Authentik with `Email Claim = preferred_username` to drive username from the preferred_username claim and expected the standard `email` claim (which the ID token also carries) to populate the user's email field. Result: username was correctly set from `preferred_username`, but `User.email` came out empty. Cause: `backend/app/api/routes/mfa.py::_resolve_provider_email` reads only `claims[provider.email_claim]`. With `email_claim="preferred_username"` and `preferred_username="jdoe"`, the value fails the SEC-2 email shape check (no `@`) and returns `None`. The auto-create-users branch then constructs `User(email=None, …)` and stores `UserOIDCLink(provider_email=None)` even though `claims["email"]` carries a perfectly valid `jdoe@example.com`. **Fix**: new helper `_resolve_standard_email_for_user_record(provider, claims, provider_sub)` reads the standard `email` claim independently and applies the same Fall A/B logic (shape check, `require_email_verified` strict / permissive split, explicit `email_verified=False` drop). The auto-create-users branch in `oidc_callback` now resolves `user_email_for_storage = provider_email or _resolve_standard_email_for_user_record(...)` and uses that for both `new_user.email` and the `UserOIDCLink.provider_email` record. **Scope is deliberately narrow**: the fallback is invoked only when `provider.email_claim != "email"` AND the primary resolver returned `None` AND the auto-create-users branch is taken. The auto-link-existing-accounts gate above remains on the primary `provider_email` — it does NOT consult the fallback. This preserves every existing GHSA-shape guard: Fall-B (`email_claim='email'` + `require_email_verified=False`) is still rejected at schema level when paired with auto-link; Fall-C (custom claim) auto-link still depends on the custom claim's shape, never on the standard `email` claim. New email fallback path runs the same shape + `email_verified` enforcement as Fall-A/B for the standard `email` claim, so an attacker-controlled IdP that sets `email_verified=False` or sends a malformed value gets dropped exactly like it would on the primary path. **Tests**: 4 in `TestOIDCStandardEmailFallback` (`backend/tests/integration/test_mfa_api.py`) — `email_claim=preferred_username` with both claims present → username from `preferred_username`, email from standard `email`; `email_claim=preferred_username` with no standard `email` claim → email stays `None` (behaviour unchanged); standard `email` with `email_verified=False` → fallback drops, email stays `None`; `email_claim="email"` (default) with `email_verified` absent → fallback path does NOT fire (Fall-A semantics preserved). Full 5478-test backend suite green. Backend ruff clean. - **Sliced `.gcode.3mf` files now render in the 3D preview and expose a Preview-3D action in the file row (#1543, reported by @Vlado-Tarakan)** — Reporter exported a multi-plate `.gcode.3mf` from Bambu Studio to the shared folder Bambuddy watches and the 3D preview tab came up empty; if he re-uploaded the same file via the file manager, the preview worked. Root cause: two paths classify `file_type` differently. `backend/app/api/routes/library.py:1343-1348` (the shared-folder scan path) does a compound-extension check and tags the file `gcode.3mf`; the upload path at the same file's `1588` does a single `ext[1:]` and tags it `3mf`. Then `frontend/src/components/ModelViewerModal.tsx:71-73` had `hasModel = normalizedType === '3mf' || 'stl'` and `hasGcode = normalizedType === 'gcode' || '3mf'` — neither matched `gcode.3mf`, so the capabilities object landed with both flags false and the modal rendered an empty bed. `FileManagerPage.tsx:858` also gated the Preview-3D context action on `file_type === '3mf' || 'gcode' || 'stl'`, so for shared-folder files the entry didn't even appear, and the type pill at `765-770` had no colour case for `gcode.3mf` so it fell through to the generic gray. **Fix** (frontend-only, no backend churn): `ModelViewerModal.tsx` introduces an `isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf'` predicate used in two places — the capabilities branch (`hasModel = isThreeMfFamily || 'stl'`, `hasGcode = isThreeMfFamily || 'gcode'`) and the plates-loading branch that previously hard-gated on `!== '3mf'` and would have returned `setPlatesData(null)` for the shared-folder file. `FileManagerPage.tsx` adds `gcode.3mf` to the Preview-3D action gate and shares the gcode blue type-pill colour so sliced-output files are visually distinguishable from source 3MFs. The compound `gcode.3mf` classification on the backend is intentionally preserved — it carries useful "this is a sliced output" semantics that other UI surfaces could use later. The `canOpenInSlicer` and `sliceableType` checks at `ModelViewerModal.tsx:269, 277-280` are deliberately left alone — a sliced output isn't openable in the slicer, and `sliceableType` already explicitly excludes `.gcode` and `.gcode.3mf` per the comment "the file type can't be sliced". **Out of scope** (separate Bambu-Studio format limitation, not a Bambuddy bug): Vlado's secondary observation that the upload-path 3D preview "shows only one plate" even though his project has 5 plates — Bambu Studio's `.gcode.3mf` export contains the g-code and model data for the active plate only, not the entire multi-plate project. The print picker enumerates plates via `gcode_*.gcode` entries inside the zip (a separate code path), which is why the user can still pick the plate at print time. The empty-bed fix is the data point that closes the user-visible bug. **Tests**: existing full 2043-test frontend suite green; no test asserted on the unsupported `gcode.3mf` capabilities branch (the change is additive — `3mf` and `stl` and `gcode` behaviours are unchanged). Frontend build clean. - **Connected-edge reconciliation closes the missed-PRINT-COMPLETE loop that produced ghost replays on smart-plug power cycles (#1542 follow-up, reported by @vixussrl-ui)** — Reporter ran a fresh trace after the doubled-extension fix landed and found a distinct second cause behind his ghost prints, hitting 4-of-4 of his A1s. Timeline: 22:50 PRINT START → print runs all night → MQTT disconnects multiple times (A1's keepalives are unstable on his network) → print finishes during one of those disconnect windows so PRINT COMPLETE is never observed → smart plug cuts power on idle → power resumes for the next scheduled print → firmware auto-replays the leftover `.3mf` from the SD card → Bambuddy reconnects to a fresh PRINT START for the ghost. The existing IDLE-after-RUNNING completion check at `backend/app/services/bambu_mqtt.py:3022` was meant to catch the simple disconnect-then-finish case via `_previous_gcode_state` preserved across reconnects, but with multiple disconnect/reconnect cycles + a smart-plug power-off that Bambuddy can't distinguish from any other transient drop, the IDLE window that branch needs simply never reaches it. The SD `.3mf` lingers, the firmware ghost-replays every power cycle, and the loop repeats until the operator notices. **Fix**: a new connected-edge reconciliation pass — new `reconcile_stale_active_prints(printer_id)` in `backend/app/main.py` queries archives in `status="printing"` for the printer at MQTT (re)connect time and synthesises `on_print_complete(status="aborted")` for any whose print can't actually be running anymore. The decision is made by a pure `_is_active_archive_stale(archive, state)` function with three triggers: (1) current printer state is terminal (IDLE / FINISH / FAILED) — covers the clean disconnect-then-finish case the existing #3022 branch was already trying to handle; (2) printer is running but with a different `subtask_id` than the archive — Bambu firmware mints a fresh `subtask_id` for each print including the ghost-replay it runs after a power cycle, so a mismatch is unambiguous evidence the in-DB archive is no longer the print on the printer; (3) printer is running but `subtask_name` is empty — the printer doesn't know what it's running, archive reference is broken. PAUSE / PREPARE / SLICING / RUNNING with matching subtask are intentionally left alone — false positives there cost a single misreported "aborted" status that the real PRINT COMPLETE would have overwritten anyway, while a false negative is the ghost-print loop being reported. The synthesised `on_print_complete` reuses the existing chain (SD cleanup, status update, usage tracker, notifications) — no reimplementation, no duplicate event when real completion later fires (the second call sees `status != "printing"` and falls through). Status `"aborted"` is the conservative label; we have no progress evidence to promote to `"completed"`. **Wiring**: new `_printer_reconciled_since_connect: dict[int, bool]` edge tracker at module scope, checked at the start of `on_printer_status_change` — when `state.connected` flips False → True (which covers both Bambuddy startup with no prior connection AND a mid-session MQTT reconnect), reconciliation fires exactly once for that connection. Setting the edge to True BEFORE the spawned task starts prevents concurrent status updates within the same connection from re-triggering it. **Concurrency**: reconciliation runs as `asyncio.create_task` so it doesn't block the WebSocket dedup / broadcast logic that on_printer_status_change is the hot path for. **Ghost-print collateral worth being explicit about**: if the ghost is already running when reconciliation fires, the synthesised SD-cleanup will hit 550-file-locked (firmware locks the file during print, same cause as the #1542 first case). The cleanup retries 3× then logs "lingering" — same as any other in-print cleanup attempt. The ghost runs to completion, its own end-of-print cleanup deletes the file, and the next power cycle has nothing to replay. The loop breaks even when reconciliation can't physically delete the file mid-ghost. A perfect cancel would require sending a `print_stop` MQTT command to the printer, which is invasive and explicitly out of scope. **Tests**: 21 in `test_reconcile_stale_active_prints.py` — `TestIsActiveArchiveStale` covers all three stale triggers with case-insensitive state matching, the four healthy-no-op cases (RUNNING / PAUSE / PREPARE / SLICING with matching subtask), the IDLE-overrides-subtask-match precedence, and the missing-subtask_id edge cases that fall through to the subtask_name check. `TestReconcileStaleActivePrints` covers the orchestrator: no-status, disconnected-status, and no-active-archives all short-circuit; a stale archive produces a synthesised `on_print_complete(status="aborted", _reconciled=True)` payload with the archive filename; a healthy in-flight archive doesn't fire any completion; an exception inside one archive's synthesis doesn't block the rest or propagate to the caller. Full 5399-test backend suite green (5378 + 21 new). Backend ruff clean. - **Fallback-archive MQTT filament extraction now actually fires for real prints (#1533 follow-up, reported by @JmanB52D)** — Reporter updated to 0.2.5b1 expecting the #1533 fix to populate filament fields on his P2S virtual-printer prints when the .3mf is locked. His support bundle showed Bambuddy still creating fallback archives with NULL filament fields even though the print-start log line proved AMS-0-T0 had PETG loaded at the moment the helper should have read it (`AMS 0: T0(type=PETG, color=FFFFFFFF, …)`). Cause: the #1533 helper `_extract_filament_data_from_mqtt(data)` in `backend/app/main.py` only looked at `data["ams"]`, but the dict that `on_print_start` actually receives at runtime is the wrapper shape `{"filename", "subtask_name", "remaining_time", "raw_data": , "ams_mapping"}` that `backend/app/services/bambu_mqtt.py:2971-2980` constructs — so `data["ams"]` was undefined on every real call and the helper silently returned `{}`, leaving the fallback archive's `filament_type` / `filament_color` NULL. The 15 unit tests that shipped with #1533 all passed the bare inner shape directly and never exercised the callback wiring, so the regression slipped through the green build. **Fix**: the helper now resolves `data["raw_data"]["ams"]` first (the callback shape) and only falls back to `data["ams"]` when the wrapper isn't present (preserves the inner-shape callers from the existing tests). Defensive: a non-dict `raw_data` (e.g. partial MQTT decode failure) falls through to the inner lookup instead of crashing. **Tests**: 5 new in `TestOnPrintStartCallbackShape` (`backend/tests/unit/test_fallback_archive_mqtt_filament.py`) — wrapper payload with ams_mapping resolves to the inner data; wrapper with no ams_mapping lists all loaded slots; the existing inner-shape callers still work after the additive wrapper lookup; missing `raw_data` returns `{}` instead of raising; junk `raw_data` (string) doesn't shadow a present inner `ams`. Full 5378-test backend suite green. Backend ruff clean. **What this does NOT fix**: per-filament gram usage still needs the actual .3mf — the printer locks it during print (P-line firmware behaviour, not a Bambuddy bug), and the existing 19 FTP candidate paths + directory probes are expected to 550 in that window. Per-print filament type and colour are the data point that drives the AMS-expansion planning the reporter explicitly called out, so this is the fix that moves the needle for him. - **Assigning a spool no longer shows a profile-mismatch warning when only the slicer profile differs, and the warning now states the AMS slot will be reconfigured (#1552, reported by @anthonyma94)** — Reporter assigned a spool to a slot whose stored slicer profile (e.g. "Bambu PLA Matte") differed from the new spool's profile (e.g. "Bambu PLA Basic"), got a warning popup with only Cancel / Assign Anyway, and was under the impression that confirming the popup just linked the spool in Bambuddy's DB without touching the AMS — i.e. that he then had to manually open Configure AMS Slot to push the new profile to the printer. The auto-push has actually been in place since the assign route existed: `backend/app/api/routes/inventory.py::assign_spool` calls `apply_spool_to_slot_via_mqtt` after upserting the SpoolAssignment row, which publishes both `ams_filament_setting` (tray_info_idx, tray_sub_brands, color, temps) and `extrusion_cali_sel` (K profile) over MQTT, and `backend/app/api/routes/spoolman_inventory.py::assign_spoolman_slot` does the same on the Spoolman side. The only short-circuit is when the firmware explicitly reports the slot empty (`tray_state ∈ {9, 10}`), in which case `main.py::on_ams_change` deferred-replays the configure as soon as a spool appears. So the popup was creating friction without revealing what it actually did. **Two changes**: (1) `AssignSpoolModal.tsx` + `spoolbuddy/AssignToAmsModal.tsx` no longer fire the mismatch popup for *profile-only* mismatches — `if (materialMatchResult !== 'exact')` replaces the old `materialMatchResult !== 'exact' || !profileMatches`, and the `'profile'` member is dropped from the `mismatchType` union (the standalone profile branch in both popup render bodies is removed as dead code). Material mismatch — where Bambu firmware can refuse the print because the type is wrong — still warns. (2) Every firing warning (material, partial, material+profile, partial+profile) now appends a new line via the new `inventory.assignReconfigureNote` i18n key: "The AMS slot will be reconfigured to use the spool's profile." This makes the Assign Anyway button's effect explicit instead of leaving users to guess. **i18n**: real translations across all 9 locales per [[feedback_translate_dont_fallback]]; parity script clean at 4999 leaves per locale. **Tests**: existing 14 `AssignSpoolModal` + 7 `AssignToAmsModal` tests pass unchanged — no test asserted on the profile-only popup firing. Frontend build clean, full 2043-test suite green. **Open follow-up**: if anthonyma94 confirms after this change that his slot *still* shows the old profile after Assign Anyway, the real bug is in `apply_spool_to_slot_via_mqtt`'s tray_info_idx / setting_id resolution for his specific spool shape — would need his spool's `slicer_filament` value plus the live tray state to diagnose. - **Transparent / clear filament now selectable and rendered as transparent end-to-end in the built-in inventory (#1545, reported by @Synec5, confirmed by @CMW-ISS)** — Reporter wanted to select a transparent filament colour in the spool editor; CMW-ISS independently confirmed on v0.2.5b1 that AMS-detected transparent spools were silently labelled "Black" in the filament-mapping dropdown because the colour name resolver dropped the alpha byte and the underlying RGB `000000` HSL-bucketed to "Black". Spoolman already supported 8-digit `RRGGBBAA` hex; the built-in inventory didn't. Five distinct sites collapsed alpha → 6-char RGB and had to be fixed together: (a) `frontend/src/utils/colors.ts` — `hexToColorName`, `getColorName`, `resolveSpoolColorName`, and `isLightColor` now short-circuit to `"Clear"` when the input is 8 chars with alpha `00`, before either the catalog lookup or the HSL fallback can mislabel transparent as black; `isLightColor` returns `true` for clear so text contrast matches the light/mid-gray checkerboard underlay the swatch paints. (b) `frontend/src/utils/amsHelpers.ts::normalizeColor` no longer unconditionally strips the alpha byte — it preserves `#RRGGBBAA` when alpha < `FF` so the AMS-side colour reaches CSS `fill=` / `backgroundColor` as a translucent value instead of a solid one; opaque colours still emit `#RRGGBB` and `normalizeColorForCompare` (which DOES strip alpha) is unchanged so type/colour matching for auto-mapping is unaffected. (c) `backend/app/api/routes/printers.py::get_available_filaments` no longer truncates `tray_color` to 6 chars before emitting it on `/printers/available-filaments` — both the AMS and `vt_tray` branches now pass the full `#RRGGBBAA` through; the dedup key still uses the 6-char RGB so two slots that share an RGB but differ only in alpha still merge into one filament requirement. (d) `frontend/src/components/spool-form/constants.ts` gained a `{ name: 'Clear', hex: '00000000' }` entry to `QUICK_COLORS` — the only 8-char preset, because the native `` can't pick alpha and a dedicated swatch is the only UX that lets the user actually choose transparency. (e) `frontend/src/components/spool-form/ColorSection.tsx` reworked the hex draft contract: previously the hex input was hardcoded to 6 chars and every commit path unconditionally appended `'FF'`, so even pasting `00000000` got truncated to `000000FF` (solid black). Now: the draft accepts up to 8 hex chars; a 6-char commit appends `FF`, an 8-char commit passes through verbatim; on blur a 7-char draft (RGB + one alpha nibble) right-pads the nibble to `0` instead of jumping back to 6-char-pad-RGB; the `selectColor()` helper that the preset swatches call only appends `FF` when the preset is 6 chars, so the new `Clear` swatch lands as `00000000` in `formData.rgba` instead of `00000000FF`. `currentRgba` is canonicalised to 8 chars uppercase and `isSelected()` matches on the full rgba so `Clear` (`00000000`) doesn't collide with `Black` (`000000FF`) in the swatch highlight. (f) Two new shared helpers in `frontend/src/utils/colors.ts` — `getSwatchStyle(rgba)` returns a `{ backgroundColor }` for opaque colours and a `{ backgroundImage, backgroundSize }` 8px checkerboard for alpha=00 (use for div / button backgrounds); `spoolColorString(rgba)` returns a hex string that preserves the alpha byte when alpha < FF (use for SVG `fill=` props and other single-string colour contexts where the consumer can interpret 8-char hex natively). Applied to every simple-swatch site that previously did `style={{ backgroundColor: '#' + rgba.slice(0, 6) }}` or passed a 6-char fill to an SVG icon — those sites would have rendered Clear spools as solid black after the cream rewrite was removed: the three preset rows in `ColorSection.tsx` (recent / catalog / fallback), the spool checkbox swatch in `LabelTemplatePickerModal.tsx`, the per-card colour dot + the SVG `SpoolCircle` in `SpoolBuddyInventoryPage.tsx`, the assigned-spool indicators in `SpoolBuddyAmsPage.tsx` (both internal and Spoolman branches), the four selected-spool summary swatches + the simple-view's spool dot in `SpoolBuddyWriteTagPage.tsx`, the lead-spool indicator in `ForecastPanel.tsx`, the header swatch in `AssignToAmsModal.tsx`, the two spool-list dots in `AssignSpoolModal.tsx` (internal + Spoolman columns), and the `SpoolIcon` fed by `InventorySpoolInfoCard.tsx` / `TagDetectedModal.tsx` / `SpoolInfoCard.tsx` / `LinkSpoolModal.tsx` (which now pass the full 8-char rgba — SVG `fill=` interprets translucent values correctly). `FilamentSwatch.tsx`'s tooltip title fallback also widened so the on-hover hex code shows `#00000000` for a Clear spool instead of misreporting it as `#000000`. **What is intentionally NOT changed**: the native `` value in `SpoolBuddyWriteTagPage.tsx`'s simple-view picker keeps its 6-char hex — that input element doesn't support alpha, and its onChange handler still sets rgba back to opaque `FF` (which is correct behaviour: the user explicitly picked a colour via the picker, not transparency). The colour-sort comparator in `LabelTemplatePickerModal.tsx::colorSortKey` keeps its 6-char alpha-strip — transparent spools sort into the same bucket as black/neutrals which is the right behaviour for ordering. The label-renderer in `backend/app/services/label_renderer.py` keeps its 6-char alpha-strip in `_hex_code_label` because the printed text on a physical label can't show transparency — `_color_from_hex` does honour the alpha byte for the printed swatch fill (alpha=00 → invisible swatch on the label, which is the honest physical answer). The Spoolman auto-sync's `_find_or_create_filament` in `backend/app/services/spoolman.py` still strips alpha when looking up the Spoolman catalog because Spoolman's filament catalog schema only supports 6-char `color_hex` — a transparent AMS spool synced into Spoolman will now match against a `000000` (Black) Bambu Lab filament entry instead of the pre-fix synthetic "PLA Basic" cream entry (RGB `F5E6D3`); both are inaccurate, the post-fix behaviour is at least honest about which colour the catalog has chosen rather than silently inventing a cream spool — users on the Spoolman backend can manually correct the filament assignment if desired. (g) **Removed the cream rewrite** at `backend/app/services/spoolman.py::parse_ams_tray` that silently replaced AMS-reported `00000000` with `F5E6D3FF` ("Light cream/natural color"). That rewrite was a workaround from when the swatch renderer couldn't show alpha — `filamentSwatchHelpers.ts::buildFilamentBackground` already paints a checkerboard underlay for alpha < FF (added in #1154), so the rewrite has been hidden technical debt that made every AMS-detected transparent spool land in inventory as cream instead of clear, with no signal to the user that a colour was substituted. AMS-synced spools now keep their true `00000000` value; the swatch renders the checkerboard; `getColorName` resolves to "Clear". (h) `backend/app/services/spool_tag_matcher.py::create_spool_from_tray` short-circuits the colour-catalog lookup when `rgba` is alpha=00 and stores `color_name="Clear"` directly — without this, a Bambu-RFID transparent spool would resolve against the `#000000` row in the catalog (or `Black` via the HSL fallback) before the frontend's name resolver ever sees it, defeating the alpha-aware fix in `colors.ts`. **Tests** — `src/__tests__/utils/colors.test.ts`: 5 new assertions covering alpha=00 → "Clear" for `hexToColorName`, `getColorName` (including precedence over a catalog entry on the same RGB), and `resolveSpoolColorName`; one existing assertion changed from `12345600` (which now correctly resolves to "Clear") to `123456FF` to keep its intent of "unknown opaque colour returns null". `src/__tests__/components/spool-form/ColorSectionHexInput.test.tsx`: header docblock rewritten to reflect the new 0–8 char draft contract; the "truncates 7–8 char pastes to RGB" test replaced with two new tests — `'0011223344'` paste now truncates to the leading 8 chars (`00112233`) and commits verbatim with no `FF` append, and a 7-char draft on blur pads to 8 with a trailing `0` instead of jumping back to RGB. 17 colours tests, 9 hex-input tests, 53 useFilamentMapping tests, 14 FilamentOverride tests, 10 FilamentSlotCircle tests, 6 `/printers/available-filaments` integration tests, 50 Spoolman API integration tests all green. Backend ruff clean; frontend build clean; i18n parity clean at 4998 leaves per locale. **What this does NOT change**: Spoolman-mode parity is preserved — Spoolman's own picker already supported 8-digit hex and `inventory.py:119` / `spoolman.py:887-889` already passed `00000000` through verbatim (the 6→8 char `FF` pad only fires when `len == 6`), so no parallel mutation is needed on the Spoolman-mode write path. Existing inventory rows that were *already* rewritten to `F5E6D3FF` stay as cream until the next AMS sync overwrites them — a one-time edit is the only path to recover them, and dropping the rewrite means future AMS syncs land the true value. - **Virtual-printer MQTT no longer drops idle slicer connections at exactly 60 s (#1548, reported by @hollajandro)** — Reporter pointed OrcaSlicer at a Bambuddy virtual printer and got a clean MQTT/TLS connect, successful auth, and a normal pushall/get_version exchange — then the slicer dropped exactly ~60 s later, every time, even after a fresh trust of the VP CA, a logged-out Bambu account, and toggling VP mode. Trace from his support bundle: 5 consecutive connect→disconnect cycles all exactly 60 s apart, with no intervening client packets after the initial exchange. Root cause: `backend/app/services/virtual_printer/mqtt_server.py::_handle_client` used a **hardcoded `timeout=60` on every per-packet read**, and `_handle_connect` two functions below explicitly skipped the keepalive field from the CONNECT payload (`# Skip keepalive` / `idx += 2`). So no matter what the client negotiated, the VP server would close the socket after 60 s of silence — and OrcaSlicer's normal pattern after the initial exchange is to sit quietly waiting for the printer to push status updates, which a virtual printer with no real state changes doesn't do. The real Bambu firmware honours the client's keepalive (MQTT spec §3.1.2.10 / §4.4: server must allow 1.5× the negotiated value before disconnecting), which is why Orca works against a real P1S but failed at exactly 60 s against the VP. **Fix**: `_handle_connect` now parses the 2-byte big-endian keepalive value from the CONNECT payload and returns it alongside the auth bool (`tuple[bool, int]`). `_handle_client` uses that to set its per-packet read timeout to `1.5 × keep_alive` after a successful CONNECT, or `None` (no timeout) when the client opted out with `keep_alive == 0` per spec. The 60 s default is retained for the *initial* read before CONNECT arrives, so a TCP-connect-but-never-send still gets reaped. **Tests**: 7 in `test_vp_mqtt_server.py` — `TestHandleConnectKeepalive` (4: returns negotiated value on success, returns 0 for opt-out, returns `(False, 0)` on auth fail / parse error so the caller's tuple-unpack never crashes), `TestHandleClientHonoursKeepalive` (3: idle client with `keep_alive=180` is still alive past the old 60 s boundary; `keep_alive=2` closes idle in ~3 s; a PINGREQ inside the window resets the timeout and the connection exits via DISCONNECT instead of timeout). The integration-style tests feed a synthetic CONNECT into a real `asyncio.StreamReader` and drive the handler on an event loop, so the timeout math is exercised end-to-end, not just unit-mocked. Backend ruff clean. - **A1 no longer auto-replays the previous print after a power cycle when the library row's filename has a doubled `.gcode.3mf` (#1542, reported by @vixussrl-ui)** — Reporter has seven A1s powered through Tuya smart plugs + Home Assistant. After every plug-driven auto-off, turning the printer back on would sometimes start the previous print on its own. Trace from his support bundle: the library row in his DB had `archive.filename = "Cube (1).gcode.3mf.gcode.3mf"` — the `.gcode.3mf` suffix had been appended twice somewhere during the file's import. The dispatcher's `archive.filename` → SD-card-name derivation only stripped ONE trailing `.gcode.3mf`, so the upload landed at `/Cube_(1).gcode.3mf.3mf`. The print ran fine, but the post-print SD cleanup in `main.py` derived its delete target from `subtask_name + ext` (`/Cube_(1).3mf`, `/Cube_(1).gcode`) — neither matched the actually-uploaded path, both 550'd three times, and the real file lingered on the SD card. On next power-up the A1 firmware picked up the leftover .3mf at the SD root and started printing it, exactly like the P1S behaviour the original Issue #374 cleanup was meant to prevent. **Two structural fixes, both shipped together** (no follow-ups per [[feedback_no_followups]]): (1) **shared name derivation**. New `derive_remote_filename(filename)` helper in `backend/app/utils/filename.py` iteratively strips trailing `.gcode.3mf` / `.3mf` suffixes until the bare stem remains, then appends a single `.3mf` and underscore-replaces spaces (the firmware parses `ftp://{filename}` as a URL, spaces break it). Iterative strip handles the doubled-suffix data; the previous single-iteration strip silently fell through to "append .3mf to whatever's left", which is how doubled extensions ended up on the SD card in the first place. The helper is the single source of truth for the SD-card target name — three previously-duplicated upload sites now route through it: `_run_reprint_archive` and `_run_print_library_file` in `backend/app/services/background_dispatch.py`, and the queue dispatch in `backend/app/services/print_scheduler.py`. (2) **cleanup uses the same algorithm as upload**. The post-print SD cleanup in `main.py` now fetches `archive.filename` when `archive_id` is resolved and tries `derive_remote_filename(archive.filename)` FIRST, with the legacy `/{subtask_name}.3mf` and `/{subtask_name}.gcode` paths kept as fallbacks for archive-less prints (subtask never matched any archive) and for older naming variants. De-duped when the primary target equals one of the fallbacks, so the happy-path delete count is unchanged. On the reporter's case the new primary candidate is `/Cube_(1).gcode.3mf.3mf`, matching the on-card file and deleting it cleanly — no more ghost print. **Out of scope** (separate concern): the upstream import path that produced the doubled `.gcode.3mf.gcode.3mf` filename is not addressed here — the iterative strip in `derive_remote_filename` defends against it everywhere it matters (upload target, cleanup target), so any future user with the same legacy data still gets clean dispatch and cleanup. **Defensive hardening caught in the first integration run**: the initial helper had no input type check, just a `while True` strip loop with `endswith` / slice. When a unit test mock (`unittest.mock.MagicMock`) was passed in by accident via the new cleanup path, `mock.endswith(".gcode.3mf")` returned a truthy `MagicMock` on every iteration and the slice `stem[:-10]` returned another `MagicMock` — the loop never reached the `else: break` branch. Each iteration allocated a fresh `MagicMock` until the LXC cgroup OOM-killer reaped the pytest worker at 61 GB anon-rss (visible in `journalctl -k` as `oom_memcg=/lxc/109` with `CONSTRAINT_MEMCG`). Fixed by adding an `isinstance(filename, str)` guard that raises `TypeError` instead of entering the loop — turns the silent infinite allocation into a loud, debuggable error. The same guard protects production: if a corrupt DB row or ORM edge case ever surfaces a non-str `archive.filename`, the cleanup logs a warning via its outer `try/except` instead of OOMing the backend. **Tests**: 10 in `TestDeriveRemoteFilename` in `test_filename_validation.py` (single `.gcode.3mf` strip, single `.3mf` strip, bare stem appends `.3mf`, space→underscore, the literal `Cube (1).gcode.3mf.gcode.3mf` reproducer from #1542 → `Cube_(1).3mf`, doubled `.3mf.3mf`, mixed `.gcode.3mf.3mf`, raw `.gcode` preserved as `.gcode.3mf` since `.gcode` alone is a valid sliced file, idempotence — running the helper on its own output is a no-op, Unicode stem preserved, **type guard** — `MagicMock` / `None` / `int` inputs all raise `TypeError` with a clear message instead of entering the loop). 315 dispatch + print-complete-path tests green (`test_phantom_print_hardening.py`, `test_print_start_assigns_printer_id_to_vp_archive.py`, `test_print_start_expected_promotion.py`, `test_cost_tracking.py`, `test_print_queue_api.py`'s `TestAbortedStatusNormalisation` — which was the suite that originally OOM'd, now passes in 2 s serial / 12 s under `-n 30`). Backend ruff clean. - **Print filenames with FAT32-illegal characters now rejected at rename/upload/queue time instead of failing at FTP (#1540, reported by @anthonyma94)** — Reporter could rename a library file to `L|R.3mf`, and the PUT `/library/files/{id}` endpoint accepted it because `library.py:4011` only blocked `/` and `\`. The pipe (and the rest of the FAT32/exFAT-illegal set `< > : " / \ | ? *`, control chars, trailing dots/spaces) flowed through to FTP upload time, where the printer's SD card rejected the create with `553 Could not create file` — far from the rename action that caused it. Bambu Studio refuses these names client-side in its save dialog; Bambuddy now does the same. **Fix**: new `backend/app/utils/filename.py` exporting `validate_print_filename(name)` and `InvalidFilenameError` — single source of truth for the rejected set (Bambu-Studio-parity: the nine chars above, control codes 0x00-0x1F, empty/whitespace-only, bare `.`/`..`, trailing space or dot, and 255 UTF-8 bytes max). Wired into three boundaries: (a) `update_file` at `library.py` replaces the path-separator-only check; (b) `upload_file` at `library.py` rejects bad multipart-upload filenames before they're persisted; (c) `print_library_file` adds a pre-flight check so older library rows that pre-date the rename validation fail with an actionable 400 instead of an obscure FTP 553; (d) `add_to_queue` at `print_queue.py` same pre-flight so queued files don't sit waiting just to fail at dispatch. The print/queue checks deliberately refuse rather than auto-rename — silently rewriting user filenames was the wrong UX (Studio doesn't, and the user explicitly chose that name). Existing rows with illegal names are left alone; users see a clear error pointing at rename. **Frontend**: the rename modal in `FileManagerPage.tsx` now mirrors the same character set client-side, shows the offending char inline as a red error below the input, and disables the Rename button while invalid — matches Bambu Studio's instant feedback rather than a round-trip-to-400. **i18n**: new `fileManager.invalidFilenameChar` key with real translations across all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW + en) per [[feedback_translate_dont_fallback]]; parity script clean at 4998 leaves per locale. **Tests**: 26 in `test_filename_validation.py` (parameterised over every char in `INVALID_FILENAME_CHARS`, the exact `L|R.3mf` reproducer from the bug, empty/whitespace/`.`/`..`, control chars, trailing space/dot, byte-length cap with multi-byte UTF-8 to verify it's bytes not codepoints). Backend ruff clean; frontend build clean. - **Fallback archives now carry MQTT-derived filament type + colour when the 3MF can't be downloaded (#1533, reported by @JmanB52D)** — Reporter (lead of a maker-space 3D Fab area) was evaluating Bambuddy partly to count filaments per print for AMS expansion planning; print log was showing "—" in the filament column for every job. Trace: a P2S in VP proxy mode where the slicer's .3mf upload lands on the real printer's SD card, then the printer locks the file mid-print and refuses every FTP read (the existing fallback-archive code path in `main.py:2596`, originally added for P1S/A1 printers, anticipates this: *"FTP has file size limitations"* — same effective behaviour on P2S). The user log shows ~12 FTP candidate paths attempted on every print start, every one returning 550, then directory listings on `/cache /model /data /data/Metadata` also returning 550, then the fallback archive being created with `file_path=""` and **every filament column NULL** — even though the MQTT print-start payload already had the AMS state and the slicer's slot-per-print-filament mapping sitting in `data["ams"]["ams"]` / `data["ams_mapping"]`. **Fix**: new `_extract_filament_data_from_mqtt(data, ams_mapping)` helper in `backend/app/main.py` (placed next to the existing `_get_start_ams_mapping`) walks `data["ams"]["ams"][*].tray[*]` to build a global-tray-id → (tray_type, tray_color) map, then narrows to slots referenced by `ams_mapping` if present (slicer order preserved; -1 entries for VT-tray skipped), or falls back to every loaded slot otherwise. Output is a comma-separated `filament_type` + `filament_color` in the same shape the 3MF extractor produces — so the inventory page, Quick Stats filament rollup, and `len(filament_type.split(','))` per-print count all light up identically for fallback rows. Truncated to the model's column limits (50 / 200). Defensive against malformed MQTT shapes (non-dict entries, non-int ids, missing fields) since this runs in the print-start hot path and a raise would break print logging entirely. The fallback `PrintArchive(...)` constructor now passes `filament_type=` / `filament_color=` from the helper. **What this is NOT**: not per-filament gram usage (that needs the 3MF's `slice_info.config` or a deep AMS layer-delta integration via `usage_tracker`) — only types and colours. The user explicitly asked for "the number of filaments used to know if or when we need to expand AMS units", which is exactly what this gives them (`SELECT COUNT(DISTINCT split(filament_type, ',')) ...` or the existing inventory count surfaces). A separate, larger piece of work to capture the .3mf in VP proxy mode at upload time (by sniffing FTP STOR in `tcp_proxy.py`) is the real long-term fix for any user who wants full 3MF-derived archive metadata in proxy mode; it's not bundled here. **Tests**: 15 in `test_fallback_archive_mqtt_filament.py` (`backend/tests/unit/`) covering: empty / malformed / no-loaded-slot payloads return `{}`; the no-mapping path lists every loaded slot in ascending global-id order with colours uppercased; an `ams_mapping` filters to and reorders by the slicer's order; VT-tray sentinels (`-1`) are filtered; dual-AMS layouts resolve `unit*4 + tray` correctly across units; a mapping pointing at unknown slots falls through to the known subset, but an entirely-unknown mapping returns `{}` rather than misreporting from the all-slots fallback; both column-limit truncations enforced; missing-colour-but-present-type emits `filament_type` only; defensive against non-dict/non-int garbage in the AMS list without raising. Existing 22 print-start unit tests untouched and green. Backend ruff clean. - **SpoolBuddy: Tare status banner no longer sits at "Waiting for device..." forever (#1536, reported by @flom89)** — On the SpoolBuddy kiosk's Settings → Scale (Waage) tab, pressing TARE wrote the "Tare command sent. Waiting for device..." banner but had no mechanism to resolve it. The daemon writes back through `POST /spoolbuddy/devices/{id}/calibration/set-tare` (which stamps `tare_offset` + `last_calibrated_at` on the device row), the device list query already polls every 10 s, but `handleTare` in `frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx` was set-and-forget — the banner persisted indefinitely. The "Calibration complete!" banner on the full calibration flow had the same shape and stayed forever too. **Fix**: a completion watcher that snapshots `device.last_calibrated_at` when TARE is pressed, sets an `awaitingTareSince` state, invalidates the device-list query every 1 s while that state is active (so detection responds within ~1 s instead of waiting on the 10 s background poll), and when `last_calibrated_at` advances past the snapshot flips the banner to "Tare complete!" with a 3 s auto-dismiss timer. A 15 s timeout on the watcher fails open to "Tare timed out — is the SpoolBuddy daemon running?" so a dead daemon doesn't leave the user staring at the spinner. The Calibration-complete success banner and the calibration-failed error banner now share the same auto-dismiss helper (3 s success, 5 s error). All timers are owned by a `useRef` that cleans up on unmount; pressing TARE while a previous dismiss is queued cancels the old timer. **i18n**: two new keys (`spoolbuddy.settings.tareComplete`, `spoolbuddy.settings.tareTimedOut`) translated into all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW + en) per [[feedback_translate_dont_fallback]] — no English fallbacks. Parity script passes at 4997 keys × 9 locales. Frontend build clean. - **ntfy notifications: honest User-Agent + actionable error when the server is behind a Cloudflare challenge (#1534, reported by @apizz)** — Reporter pointed an ntfy server behind a Cloudflare Tunnel at Bambuddy and got `HTTP 403: ...Just a moment...` on every Test click. They reproduced the same response with a plain `curl -H "Authorization: Bearer " -d "test" https://ntfy.example/` — confirming the 403 originates from Cloudflare's JS challenge intercept (Bot Fight Mode / "Under Attack" mode), not from Bambuddy or ntfy. Cloudflare returns its interstitial HTML to any non-browser client at the edge, so the request never reaches the user's ntfy backend at all. Bambuddy can't solve a JS challenge from a backend — the only real fix is on the user's Cloudflare side (a security-skip rule for the hostname/path, disabling Bot Fight Mode for that hostname, or fronting the server with Cloudflare Access using a service token). Two improvements shipped to make this footgun self-diagnosable for the next user who hits it. **(1) Honest User-Agent on the notification HTTP client.** `backend/app/services/notification_service.py` was the one outbound httpx client in the codebase that didn't set the project-standard `Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)` UA — it leaked `python-httpx/` instead. Brings it in line with `bambu_cloud` / `makerworld` / `firmware_check` / `inventory` (all unified during the May 2026 compliance pass) and makes Bambuddy a more obvious citizen to upstream WAFs and proxy operators. Won't defeat Cloudflare's JS challenge (the user's curl test proves CF blocks regardless of UA) but it's a consistency / hygiene fix with no regression risk. **(2) Cloudflare-challenge detection on the ntfy error path.** New `_looks_like_cloudflare_challenge(response)` helper checks the response shape (`Server: cloudflare` or `cf-mitigated` header, or `...Just a moment...` body). When a 403/non-success response matches, the error returned to the UI now reads: *"HTTP 403 — ntfy server is behind a Cloudflare challenge. Bambuddy was served the JS challenge page instead of reaching ntfy. Cloudflare cannot be solved from a backend; add a Cloudflare security-skip rule for this hostname, disable Bot Fight Mode, or front the server with Cloudflare Access using a service token. (#1534)"* — actionable, points at the real fix, removes the raw HTML dump. A regular 403 (e.g. ntfy auth failure with a plain `forbidden: invalid auth token` body) still surfaces the original body so genuine auth errors stay debuggable; the interceptor only fires on the Cloudflare shape. **Tests**: 3 new in `TestNtfyOutbound` in `test_notification_service.py` — (a) the lazy-constructed httpx client carries the honest UA header on first use; (b) a 403 with `Server: cloudflare` + `Just a moment...` body produces the actionable error and does not echo `.3mf`. The file was physically written there (a path outside the user's mounted data volume — orphaned on container restart) and only the *final* `source_path.relative_to(settings.base_dir)` raised, so every retry left another orphan. Affected reporter is on a QNAP Docker host with the standard `/app/data` mount; both maintainer and triage initially diagnosed it as a Docker volume misconfiguration, but the traceback shows the bug is purely on Bambuddy's side — the user's setup was correct. **Fix**: new private helper `_resolve_source_3mf_path(archive, source_filename)` in `backend/app/api/routes/archives.py` centralises the destination computation. Normal archives still nest the source under `/source/`. Fallback archives (empty `file_path`) now land under `/archive/no_source//` instead — a deterministic, addressable location that stays inside the data volume, and the existing read sites (`download_source_3mf`, `download_source_3mf_by_filename`, the slicer-token routes, `delete_source_3mf`) all continue to work because they read back via `settings.base_dir / archive.source_3mf_path`. The helper also defensively asserts the resolved directory is inside `base_dir.resolve()` regardless of where it came from, so a row corrupted by an old import or a manual SQL edit fails with a clear 500 message ("Archive N resolves to a path outside the data directory; cannot attach source.") instead of silently writing outside the volume. Both upload sites (`upload_source_3mf` and `upload_source_3mf_by_name`, the slicer-post-processing endpoint) now route through the helper, so neither can independently drift back into the bug. **Tests**: 2 new in `TestUploadSourceThreeMF` in `backend/tests/integration/test_archives_api.py` — (a) `test_fallback_archive_source_upload_lands_under_base_dir` creates an archive with `file_path=""`, uploads a minimal valid 3MF, asserts 200 status, that the returned `source_3mf_path` is relative (not `/app/source/...`), that the file physically exists under the patched `base_dir`, and that the path is the deterministic fallback location keyed off `archive.id`; (b) `test_normal_archive_source_upload_unchanged` is the same flow against an archive with a populated `file_path`, asserting the existing `archives/test/source/.3mf` layout is preserved (regression guard against the helper accidentally changing the normal path). 57/57 in `test_archives_api.py` green under `pytest -n 30`. Backend ruff clean. **Note**: existing orphan files at `/app/source/.3mf` from prior failed retries inside an affected user's container can be safely deleted; they were never indexed in the DB, never reachable from the UI, and would have vanished on the next container restart anyway. - **SpoolBuddy weight sync no longer silently lands on a stale local row when Spoolman is enabled (#1530, reported by @chesterakl)** — Reporter (Spoolman mode, H2C, internal "manually add then NFC-link" flow) saw the SpoolBuddy "Sync Weight" button flip to "Synced!" but the Spoolman-backed inventory listing never updated. Cause: `POST /spoolbuddy/scale/update-spool-weight` (`backend/app/api/routes/spoolbuddy.py`) ran the lookup local-DB-first and only fell through to Spoolman on local miss — but the upstream `nfc/tag-scanned` route is exclusive (always-Spoolman when `spoolman_enabled=true`, after the #1119 / nfc-routing fix). When the user's local DB still held a stale `Spool` row that happened to share a numeric id with the Spoolman spool the NFC tag mapped to, the sync endpoint absorbed the update into the stale local row, returned 200 with the local `weight_used`, and the actual Spoolman spool went untouched. The support log confirms it: 17 sync attempts across two days, every line logged `SpoolBuddy updated spool 2 weight: …g on scale, …g used` (the local-branch log format) and the `SpoolBuddy updated Spoolman spool …` line (which only fires in the Spoolman branch) never appeared. The bug couldn't be reproduced on developer setups because they don't carry a leftover local row with a colliding id. **Fix**: `update_spool_weight` now routes exactly like `nfc_tag_scanned` — `_get_spoolman_client_or_none(db)` first, and that result picks the branch exclusively. Spoolman mode goes straight to Spoolman with no local-DB read; local mode does the local update and returns 404 (not "fallback to Spoolman") on a local miss. Matches [[feedback_inventory_modes_parity]] — the two inventory modes must behave identically from the user's perspective, including which row gets written. The docstring now spells out the routing contract so the next reader doesn't reintroduce the local-first read. **Tests**: 1 new regression test in `TestUpdateSpoolWeightSpoolman.test_stale_local_row_does_not_shadow_spoolman` — creates a local `Spool` with the same numeric id as a mocked Spoolman spool, posts the sync, asserts (a) Spoolman's `update_spool` was called with the correct remaining weight, and (b) the local row's `weight_used` and `last_scale_weight` are unchanged after a `refresh()` against the live DB. The existing 8 tests in that class continue to assert the Spoolman branch math (filament/spool-level tare priority, 404 / 503 mappings, 250g fallback warning). 9/9 green; 126/126 across the spoolbuddy + spoolman-filament-patch integration suites green under `pytest -n 30`. **Cleanup hint for affected users**: anyone in Spoolman mode with leftover local Spool rows from before they switched should delete those rows — they're inert under the new routing, but they were eating sync attempts under the old. Backend ruff clean. - **Paused prints no longer inflate maintenance hours (#1521, reported by @TempleClause)** — The `track_printer_runtime` background task in `backend/app/main.py` counted both `RUNNING` and `PAUSE` states equally toward `runtime_seconds`, which feeds every hours-based maintenance interval (lubricate rods, clean nozzle, check belts, etc.). Maintenance items measure *mechanical wear*, and pause time involves no motion — so a print paused overnight stretched the maintenance clock forward by ~8 h without any actual wear, triggering "lubricate rods" warnings earlier than warranted. Reporter found this by code review (no support bundle), flagged it cleanly with the exact line in `main.py` and three ranked solution options. **Fix**: option 1 (exclude PAUSE entirely) — `state.state in ("RUNNING", "PAUSE")` → `state.state == "RUNNING"`. PAUSE now follows the same path as FINISH / IDLE / PREPARE: the elapsed-time accumulator skips it, and `last_runtime_update` is cleared so a later RUNNING transition starts fresh and doesn't back-bill the pause. No setting / toggle (reporter's option 3 was deliberately the throwaway — this is a wear-tracking semantic, not a user preference); no cap (option 2) — wear during pause is zero, not "reduced". Docstring and field-comment trail updated across `main.py`, `models/printer.py:23`, and the two `api/routes/maintenance.py` route docstrings that all previously described the field as covering "RUNNING and PAUSE states". **Out of scope**: retroactive backfill of existing `runtime_seconds` values — already-accumulated pause time cannot be split out, only future accumulation is fixed. Users with hours-based maintenance intervals already set will see slower accumulation going forward (the correct outcome), so a previously-near-due item may take longer to ring than under the old behaviour. **Tests**: 3 new in `test_runtime_tracking_pause.py` pinning the new contract — PAUSE does NOT accumulate and clears `last_runtime_update`; RUNNING still accumulates and updates the timestamp; a non-active state (FINISH) clears `last_runtime_update` to prevent back-billing the idle time when the printer next goes RUNNING. The tests drive the actual `track_printer_runtime()` coroutine through a single iteration via patched `asyncio.sleep` against an in-memory SQLite DB, so they catch any regression in the predicate at the call site (not just an extracted helper). Backend ruff clean; targeted 24-test rod/runtime subset all green. - **Quick Stats: user-cancelled prints now have their own bucket and no longer drag down the Success Rate gauge (#1390 follow-up, reported by @IndividualGhost1905)** — Reporter saw `Total prints: 20 / Success: 18 / Failed: 1` and asked where the 20th print went; the breakdown only showed Successful + Failed, so a cancelled run silently inflated the total without appearing anywhere. The earlier #1390 round had committed a test that *locked in* the bug — `it('uses total_prints as denominator so cancelled/stopped events count')` asserted the gauge should divide by `total_prints`, which lumped user/queue-cancelled jobs in with quality outcomes and conflated user intent with printer performance. **Cause**: `PrintLogEntry.status` has six values in production (`completed`, `failed`, `aborted`, `stopped`, `cancelled`, `skipped`) but the Quick Stats endpoint in `api/routes/archives.py` only counted two — `completed` → Successful, `status == "failed"` → Failed — and used a raw `count(*)` for Total Prints, so the other four statuses ended up in Total without surfacing in any breakdown row. `aborted` was particularly silent: classified as a failure elsewhere in the codebase (`failure_analysis.py`, `main.py:430,1729`) but not counted toward `failed_prints` in stats. **Fix**: three-bucket classification across the whole stats surface, matching how the rest of the codebase already groups these statuses. Quick Stats now returns `successful_prints` (completed), `failed_prints` (failed + aborted — printer-detected quality failures), and a new `cancelled_prints` (stopped + cancelled + skipped — user/queue interruptions). The SuccessRateWidget gauge divides by `successful + failed` only, so cancelling a roll because you changed your mind doesn't ding the printer's success rate — a Cancelled row in the breakdown surfaces the count so it doesn't silently vanish from Total Prints. The Failure Analysis service applies the same denominator change (`failure_rate = failed / (successful + failed)`) to both the headline rate and the per-week trend, so a week with no failures but several cancellations no longer reads as a misleading 0/N. **Schema change is additive-safe**: `ArchiveStats.cancelled_prints` defaults to `0` so any historical fixture validating against the model still parses; the frontend type also defaults the display to `0` when the field is missing. **i18n**: new `stats.cancelled` key with real translations across all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW) per [[feedback_translate_dont_fallback]]; parity script clean at 4994 leaves per locale. **Tests**: existing `it('uses total_prints as denominator …')` test inverted to assert the new behaviour (40 completed / 20 failed / 35 cancelled → gauge shows 67%, Cancelled row reads 35), `cancelled_prints: 0` added to the shared mock so the unchanged-display assertion (140/150 → 93%) still holds since `140 / (140 + 10) = 93.33%` rounds identically. 33 StatsPage tests + 6 backend stats/failure tests green; frontend build + backend ruff clean. **Follow-up (cosmetic):** the new Cancelled row's Ban icon rendered in `text-bambu-gray` while the Successful and Failed icons used semantic `text-status-ok` / `text-status-error` tokens — reporter (@IndividualGhost1905) noted the asymmetry and asked for an orange to match what Archives + notification badges use for cancelled. Switched the Cancelled row to `text-status-warning` (amber-500, same token family as the other two rows), so all three icons are now semantic-token-driven and the new row matches the colour the user already associates with cancelled status elsewhere in the UI. - **VP queue mode no longer blocks BambuStudio Send while the target printer is mid-print (#1558, reported by @phieb)** — Reporter set up a non-proxy queue-mode VP with a target printer bound, started a print on the real printer, then tried Send to the VP from BambuStudio — slicer refused with the "busy" pre-flight error even though Bambuddy's whole job is to look idle so jobs queue any time. Cause traced by reporter: `SimpleMQTTServer._send_status_report` forces `gcode_state=IDLE` and storage indicators on top of the cached-as-base mirror — good — but the cached branch overrode only a handful of fields, and the live print-progress fields from the mirrored real `push_status` (mc_print_stage, mc_percent, mc_remaining_time, stg, stg_cur, layer_num, total_layer_num, print_error) passed through unchanged. The VP emitted a contradictory report (gcode_state=IDLE but mc_percent>0, stg_cur>0, ...) and BambuStudio's Send pre-flight read it as busy. Without a bound target the synthetic-stub branch reported all of these idle and Send worked — isolating the leak to the cached branch. **Fix**: in the cached branch, also override those 8 activity fields to the idle values the synthetic-stub branch uses (`mc_print_stage=""`, `mc_percent=0`, `mc_remaining_time=0`, `stg=[]`, `stg_cur=0`, `layer_num=0`, `total_layer_num=0`, `print_error=0`). Same shape as the #1228 storage-indicator overlay — internally consistent with the forced IDLE state while AMS / version / temperatures keep mirroring. **Behavioural caveat for users**: a slicer connected to the VP just for monitoring no longer sees the real printer's mid-print progress through the VP (since the cached push now reports idle). The real printer's IP / UI remains the source of truth for progress. Per the issue intent, this trade-off is explicit. **Tests**: new `test_live_progress_fields_zeroed_in_cached_branch` in `test_vp_mqtt_bridge.py::TestStatusReportCachedAsBase`. - **VP `_pending_files` / temp-file leak on every error path across the three file handlers** — Pre-fix: `_archive_file`, `_queue_file`, and `_add_to_print_queue` only popped `_pending_files` and unlinked the temp file on the success branch. When archival failed (DB outage, ArchiveService raise, queue insert error), the entry stayed in the dict — and since the FTP layer keys its "same-name STOR already in flight" guard on filename, the slicer's next retry was spuriously rejected; the upload_dir also accumulated orphan temp files indefinitely. Each handler now uses a `try / finally` that pops the marker and unlinks the temp file regardless of whether the body succeeded. 3 unit tests in `test_virtual_printer.py::TestVirtualPrinterInstance` (one per handler) inject a failure mid-flight and assert both invariants. - **VP queue position now picks `MAX(position)+1` instead of hardcoded `1`** — Pre-fix: VP-uploaded queue items always landed at `position=1`. With non-empty queues this created duplicate position=1 rows; the scheduler orders by `(printer_id, position)` so ties resolved in undefined DB-internal order, and repeat VP uploads accumulated multiple position=1 rows — making the queue's visible ordering non-deterministic and dispatching out of the user's intended sequence. Now the VP path runs the same `SELECT MAX(position) FROM print_queue_items WHERE printer_id= AND status='pending'` query the canonical `POST /print-queue/` route uses and inserts at `max_pos + 1`. Defensive `try/except` around the `.scalar()` call so a mocked DB in tests can't cause a `TypeError` from MagicMock arithmetic. 1 unit test pins the MAX+1 behaviour (with `MAX=7` the inserted item lands at `position=8`). - **VP DELETE route cleans orphan `PendingUpload` rows + on-disk upload_dir** — Pre-fix: `DELETE /virtual-printers/{vp_id}` stopped the running instance and removed the row, but the `base_dir/uploads//` directory and any `PendingUpload` rows that referenced it lingered. The user only learned the rows were orphaned by trying to archive one and getting a "file missing" → flip-to-discarded auto-handler — not exactly a clear signal. Now the DELETE handler queries `PendingUpload` rows whose `file_path` starts with the VP's upload_dir prefix, marks them `status='discarded'`, then `shutil.rmtree`s the directory after the DB commit succeeds (so a crash between commit and rmtree leaves orphan files at worst, not orphan rows pointing at a missing tree). 2 unit tests in `test_vp_delete_cleanup.py` cover the cleanup-with-orphans + clean-no-op paths. - **VP `MQTTBridge._refresh_loop` crash no longer leaks the raw_message_handler** — Pre-fix: if any exception escaped `_resolve_client` (the IP-encoding branch was the most likely culprit), `_refresh_loop` caught it with `logger.exception` and returned. The task completed `status=done` — not cancelled, not raising — so `stop()` never ran and `_unbind_client` never fired. `self._on_printer_raw` stayed registered on the live `BambuMQTTClient` and kept reading / writing `self._latest_print_state` on every real-printer message even though the VP bridge was functionally dead, creating a behaviour leak that persisted across VP restart. Now the crash exit explicitly calls `_unbind_client()` so the orphaned handler is detached even when the loop dies abnormally. - **VP `sync_from_db` serialised by `asyncio.Lock` (concurrent-PUT race)** — Two simultaneous `PUT /virtual-printers/{id}` calls (e.g. browser racing the auto-save trigger) could race the inner start/stop sequence and leave duplicate sub-services bound to the same port — split-brain state that only resolved on the next Bambuddy restart. `VirtualPrinterManager.__init__` now holds a `_sync_lock`; `sync_from_db` wraps the body in `async with self._sync_lock`. Single VP updates still complete in well under a second, so the serialisation isn't visibly slower. - **VP `_slicer_print_options` cache bounded at 128 entries with FIFO eviction** — Pre-fix: the dict that stashes the slicer's `project_file` options (so `_add_to_print_queue` can inherit timelapse / bed_leveling / flow_cali / etc.) had no bound. If the slicer sent `project_file` for a filename whose FTP upload was rejected / cancelled / non-3MF, the stash was orphaned and the dict grew one entry per such event for the VP's entire uptime. The new bound triggers eviction of the oldest entry once 128 entries accumulate. - **VP `MQTTBridge` sticky-key carry-forward now uses `copy.deepcopy`** — Pre-fix: a sticky key carried over from the previous cache was assigned by reference, sharing nested dicts/lists between the old and new state. No current code path mutates a carried-forward sticky key in place, so this was latent — but a future merge that did would corrupt both copies. Defensive `copy.deepcopy` on the carry-forward removes the foot-gun without changing observable behaviour. - **VP `MQTTBridge._refresh_loop` and `SimpleMQTTServer._send_status_report` cached-path use deepcopy** — `_send_status_report` cached branch was using `dict(cached)` — a shallow copy. Today's mutations are top-level only, but a future override that wrote into a nested dict (e.g. `online`, `upgrade_state`, `ipcam`) would corrupt the bridge cache and be read by every subsequent subscriber until the next real-printer push landed. Switching to `copy.deepcopy` removes the foot-gun. - **VP `SlicerProxyManager` lifecycle hardening** — Multiple proxy-mode fixes shipped together: (a) `_ftp_data_proxies` and `_actual_ftp_port` are pre-initialised in `__init__` instead of `start()`, so `stop()` called before `start()` finishes (rapid mode-switch race) no longer raises `AttributeError` and leaves sockets stranded; (b) `_actual_ftp_port` now tracks the iptables-redirect target when the deployment uses `REDIRECT --to-port` to let non-root containers serve on 990, and `get_status()` returns it — diagnostic was previously probing the class constant 990 and false-failing on every working redirect deployment; (c) the FTP-data-proxy `auto_close` tasks (101 of them in `FTPTLSProxy`) are now tracked on `_auto_close_tasks` and cancelled in `stop()` — previously they lingered ~60 s holding server references and could fail the next start with "address already in use"; (d) probe servers `await server.wait_closed()` on stop instead of just `srv.close()` — same rapid-restart race. - **VP diagnostic now probes both bind ports 3000 and 3002** — Pre-fix: non-proxy bind diagnostic only probed 3002. The bind server in server mode actually listens on both (plain on 3000, TLS on 3002 per `bind_server.py:BIND_PORTS`); a VP whose plain listener failed to start but TLS listener succeeded would pass the diagnostic while being half-broken. Now `port_bind` reports `pass` only when both probes succeed. New `PORT_BIND_PLAIN = 3000` constant. - **VP FTP `stop()` awaits cancelled sessions instead of `sleep(0.1)`** — A session mid-write, mid-TLS-handshake, or holding a 60 s data-read could easily outlive the 100 ms sleep, and the server's `close()` would run while underlying sockets were still in use. Now `stop()` cancels each session task and `asyncio.gather`s them with `return_exceptions=True`. Stop is a few ms slower in the typical case; worst-case bounded by whatever asyncio takes to propagate cancellation. - **VP child sub-services (FTP / MQTT / Bind / SSDP) expose `ready` event for accurate `is_running`** — See Added section for full description. - **VP per-VP TLS certificate auto-regenerates when the shared CA is rotated** — Pre-fix: `ensure_certificates` only checked that the per-VP cert file existed. When the shared CA was regenerated (its expiry within 30 days), per-VP certs on disk were still signed by the OLD CA — slicers that imported the NEW CA failed handshake. The check is now a real signature verification: `ensure_certificates` loads the on-disk per-VP cert and the on-disk CA, and verifies the cert's signature against the CA's public key via `cryptography.hazmat.primitives.asymmetric.padding.PKCS1v15`. On `InvalidSignature` (rotation detected), the per-VP cert is regenerated under the current CA. **The unit-test driven a real bug** in an earlier version of this fix: comparing Subject DN was insufficient because Bambuddy's auto-generated CAs share the same Subject Name ("Virtual Printer CA"), so DN-match returned True even after rotation. 3 tests in `test_vp_certificate_rotation.py` (reuse-when-issuer-matches, regen-when-rotated, no-CA-returns-False). - **VP `tailscale.py::get_status` now catches `asyncio.TimeoutError`** — Pre-fix: `_run_tailscale` could re-raise `TimeoutError` after killing a stuck subprocess. The `except OSError` clause in `get_status` didn't catch it, so the exception propagated all the way to the FastAPI route handler and crashed the VP management UI for any user whose host `tailscaled` was lagging. Now the except clause covers both `OSError` and `asyncio.TimeoutError`, returning a `TailscaleStatus(available=False, error=...)` either way. - **VP `certificate.py` CA save uses correct parent directory** — Pre-fix: `_get_or_create_ca` created `self.cert_dir` (the per-VP subdirectory) before writing the CA, but the CA writes target `self.ca_key_path.parent` (the shared CA dir — potentially a different path). Latent because the manager pre-creates both directories; surfaced by the path-correctness audit. - **VP `_extract_plate_id` logs failures at debug instead of silent** — Pre-fix: `except Exception: return None` swallowed any failure to parse `Metadata/slice_info.config` without a log. A malformed 3MF then produced a wrong-plate dispatch with no diagnostic trail. The except now logs at debug so support bundles capture the parse error. ## [0.2.4.4] - 2026-05-30 ### Security - **Fail-open authentication bypass on database errors — unauthenticated access to every protected endpoint during a forced DB-exception window ([GHSA-6mf4-q26m-47pv](https://github.com/maziggy/bambuddy/security/advisories/GHSA-6mf4-q26m-47pv), CVSS 9.8 critical, reported by @wondercrash)** — Two functions in the auth path caught every exception and returned the "allow" answer instead of denying the request: `is_auth_enabled` in `backend/app/core/auth.py:473` (returned `False`, treating "DB query raised" as "auth is disabled") and the global `auth_middleware` in `backend/app/main.py:5590` (caught everything and called `await call_next(request)` with a comment that explicitly said "fail open for DB issues"). An attacker who could trigger any exception during the auth probe — the reporter's documented PoC floods `/api/v1/auth/login` until the process exhausts its file-descriptor budget and the next SQLite `connect()` raises — could then hit any protected endpoint during that fail-open window with no token. CWE-636 (Failing Open) / CWE-755 (Improper Handling of Exceptional Conditions). Affected versions `>= 0.1.6`. **Impact during the window**: create a persistent admin account or API key, download the database backup (hashed passwords + encryption keys + printer access codes + MFA secrets), read/modify settings, control printers. **Fix**: `is_auth_enabled` now only returns `False` for the legitimate "settings row absent" case (`scalar_one_or_none()` returns `None` → system was never configured for auth); any actual exception propagates so the caller can deny the request. `auth_middleware` returns `503 Service Unavailable` on any probe failure instead of letting the request through. The principle applied throughout: a failure to verify the auth state means the request is denied, not granted. **Regression tests** in `backend/tests/unit/test_auth_fail_closed.py` pin the four contracts: `is_auth_enabled` propagates DB exceptions, returns `False` for the no-row case, returns `True` for `value=true`, returns `False` for `value=false`. An existing security test (`test_security.py::test_status_returns_500_on_db_error`) was renamed to `test_status_returns_503_on_db_error` and updated to accept either 500 or 503 (both fail-closed) while explicitly verifying the SQLAlchemy detail string doesn't leak in the response body. **Codebase audit**: grepped every `except Exception` in `backend/app/core/auth.py` and `backend/app/core/permissions.py` for the same shape; `_validate_api_key` catches but returns `None` which leads to a 401 downstream (fail-closed), `is_advanced_auth_enabled` in `backend/app/api/routes/auth.py` already propagates correctly, `permissions.py` has no catch-alls — no other auth-decision predicate carries this anti-pattern. ## [0.2.4.3] - 2026-05-24 ### Added - **SliceModal: "Slice all plates" toggle for multi-plate sources** — Re-slicing a multi-plate 3MF (e.g. a "parted statue" project where each plate carries a different body part) required opening the slice modal once per plate, picking the printer / process / filaments every time, and ending up with one archive per plate. The footer now has a "Slice all N plates" checkbox for multi-plate sources: tick it and the "Slice" button flips to "Slice all N plates", submitting `plate=0` instead of the picked plate index. The backend forwards this as the BS CLI's `--slice 0` "all plates" sentinel, which produces a **single output 3MF whose `Metadata/plate_N.gcode` entries cover every plate** — one slice call, one archive, every plate inside. **Filament dropdowns also adapt**: with the toggle on, they show the *union* of every plate's slot usage (a slot a plate-2 part paints with but plate 1 doesn't was previously invisible — the user could only pick filaments for the actively-viewed plate). The union is computed client-side from the existing `platesQuery.data.plates[*].filaments` payload, so no extra round-trip. The backend `SliceRequest.plate` field's range relaxed from `ge=1` to `ge=0` to admit the sentinel (the schema's docstring spells out the three semantics: `None` → default plate 1, `0` → all plates, `>= 1` → that plate). The substitute-unused-filaments pass becomes a no-op for `plate=0` (no concept of "unused" when every plate counts), which is correct — in slice-all mode every slot the project defines IS used by something. The toggle is hidden on single-plate / STL sources where it'd be meaningless. **Cross-class slice-all is handled by a per-plate loop**: BS CLI's `--arrange` is project-wide, so `--slice 0 --arrange 1` on a cross-class source consolidates every plate's objects onto a single target bed — either packing everything onto one plate or rejecting with "Some objects are located over the boundary of the heated bed" when nothing fits. When Bambuddy detects `plate=0` combined with a class crossing, it falls back to slicing each plate independently (`plate=N, arrange=true`), then merges the N single-plate 3MF outputs into one multi-plate 3MF in `merge_plate_3mfs` — overlays each plate's `Metadata/plate_N.{gcode,gcode.md5,json,png,_small.png,no_light_N.png,top_N.png,pick_N.png}` onto the first plate's base 3MF and re-assembles `Metadata/slice_info.config` to list every plate's slice block. The resulting archive's totals are the sum of each plate's print time + filament usage. New `count_plates_in_3mf` parses `model_settings.config` for `` entries to know how many plate calls to make. Cost: N × per-plate slice time; for a 5-plate Mewtwo on H2D that's ~70s wall clock vs the single-call same-class path. **Progress toast shows loop position**: each per-plate sub-slice forwards the original `progress_request_id` + callback so the toast keeps showing the sidecar's stage messages, with the snapshot augmented with `multi_plate_index` / `multi_plate_count` — the toast renders "Plate 2 of 5 • Mewtwo.gcode.3mf — Generating G-code (47%) — 23s" instead of just elapsed time. New `slice.runningWithProgressMultiPlate` i18n key translated across all 9 locales. **Per-plate cover images preserved**: BS CLI with `--arrange` regenerates plate gcodes but rarely writes a fresh `Metadata/plate_N.png`, so the merged 3MF would have only plate 1's cover. The merger now takes the source 3MF as an optional fallback and lifts the source's per-plate render (`plate_N.png` / `plate_N_small.png`) into the merged file when the sliced output is missing it — same fallback approach as the archive-card thumbnail fix. **Final test coverage**: 26 unit tests in `test_slicer_3mf_convert.py` (extract canonical model, count plates, merge with overlay / passthrough / source-thumbnail fallback / sorted plates, substitute unused-slot filaments) + 3 in `test_slicer_api.py` (arrange flag wire format on preset and bundle paths) + 9 in `test_library_slice_api.py` (guard no-op semantics, re-sliced thumbnail / bed_type lifts, **a new cross-class slice-all integration test that mocks the sidecar, asserts the backend loops per-plate with `arrange=true`, and verifies the merged archive contains `plate_1..plate_N.gcode`**) + 2 in `test_archive_service.py` (Auxiliaries thumbnail fallback) + 4 in `SliceModal.test.tsx` (slice-all toggle sends `plate=0`, toggle hidden for single-plate, plus 2 pre-existing tests for the picked-plate behaviour) + 2 new in `SliceJobTrackerContext.test.tsx` (toast prefixes "Plate X of Y" when the snapshot carries the loop fields; no prefix on plain single-plate slices). 659 backend / 42 frontend tests green; backend ruff + frontend build + i18n parity all clean. 2 new tests in `SliceModal.test.tsx` (toggle sends `plate=0` to the backend; toggle hidden for single-plate sources) plus updates to the existing plate-picker test for the new label scheme. All 9 locales translated. Frontend build clean, i18n parity green at 4983 keys × 9 locales. - **System Health — log scanner that surfaces self-fixable issues before they become support tickets** — Complements the active Connection Diagnostic with a passive check: it scans Bambuddy's recent app log against a curated catalog of known failure signatures and reports what it finds. The catalog (`backend/app/services/log_health.py`) is a deliberate allowlist — only known-bad, actionable patterns match, so a healthy install reports nothing and noisy benign churn (the occasional MQTT reconnect after a Wi-Fi blip) is gated behind a per-signature `min_count` threshold. Six seed signatures cover the recurring "layer 8" causes from the closed-issue triage: rejected access code, FTPS :990 timeout, FTPS TLS handshake failure, flapping MQTT connection, unreachable camera (RTSPS :322), and SQLite `database is locked` contention. Each finding is deduped (`occurred N×, last seen …`), classified as *you can fix this* / *environment* / *please report this*, and carries a deep-link to the troubleshooting wiki; sample log lines are sanitized (IPs, serials, access codes redacted) before they leave the process. Exposed via `GET /system/health` and surfaced on two surfaces that share one `SystemHealthPanel` component: a System Health section on the System page (on-demand re-scan), and inline in the bug reporter when the form opens — so a setup mistake gets self-resolved instead of becoming a GitHub issue. The Add-Printer and Edit-Printer dialogs also gained a setup-time pre-flight: saving now runs the connection diagnostic and, if a check fails, warns with a "save anyway" escape hatch instead of silently saving a printer that will immediately show offline. Log-reading and redaction primitives were extracted from `routes/support.py` into a shared `backend/app/services/log_reader.py` (behaviour-preserving). 13 backend tests (`test_log_health.py`, `test_system_api.py`) and 8 frontend tests (`SystemHealthPanel`, `BugReportBubble`, `AddPrinterPreflight`, `EditPrinterPreflight`); all strings translated across the 9 locales. Backend ruff clean, full unit suite green, frontend build clean, i18n parity green. - **Event-loop stall watchdog — makes a frozen backend self-diagnose (#1486 groundwork)** — Several "container hangs after adding a printer" reports share a signature that leaves nothing to act on: the HTTP server goes silent, `/health` hangs, the process may stop responding to SIGTERM — and the logs just stop mid-stream with no traceback, because a frozen asyncio event loop cannot log anything. New `backend/app/services/loop_watchdog.py` closes that blind spot: an async heartbeat re-arms `faulthandler.dump_traceback_later()` every 10s, always 30s ahead. While the loop ticks, the timer is cancelled and re-armed before it can fire; if the loop stalls, the heartbeat can't re-arm and faulthandler's dedicated C-level timer thread — which runs independently of the frozen loop — dumps **every thread's stack to stderr**. The blocked frame then appears in `docker compose logs`, turning an un-diagnosable freeze into a one-command capture. Started in the app lifespan after migrations, stopped cleanly on shutdown; 30s threshold is well above any legitimate on-loop operation, so a trip always means a real bug. 5 unit tests in `test_loop_watchdog.py` (arms the timer, idempotent start, stop disarms + cancels, heartbeat interval below the threshold, survives a re-arm error). Backend ruff clean; full app lifespan verified via the integration suite. - **Slicer: process & filament profiles filtered by the selected printer (#1325, requested by @IndividualGhost1905)** — In the server-side Slice dialog, picking a printer profile now filters the Process and Filament dropdowns to presets compatible with that printer; presets that resolve to a different Bambu model drop into a trailing "Other printers" group instead of cluttering the main list. Matching uses the slicer's own `compatible_printers` list for imported (local) presets, and falls back to the `@BBL ` name suffix for cloud and standard presets, so all three tiers are covered. Compatibility-unknown presets (custom or untagged) are never hidden. Defaults follow suit — the pre-picked process and per-slot filament now prefer a printer-compatible preset, and switching the printer re-picks any selection left incompatible. The printer and process dropdowns also default to the preset names embedded in the source 3MF's `project_settings.config` when those presets are available, instead of always taking the first listed preset. New `frontend/src/utils/slicerPrinterMatch.ts` (11 unit tests) and `extract_embedded_presets_from_3mf` (5 unit tests); `UnifiedPreset` now carries `compatible_printers`, exposed for the local tier (`backend/app/api/routes/slicer_presets.py`); the plates endpoints return `embedded_printer` / `embedded_process`. Parity green, build clean. - **Spanish (es) translation (#1243, requested by @MiguelAngelLV)** — Bambuddy now ships a full European Spanish locale. New `frontend/src/i18n/locales/es.ts` translates all 4899 keys with placeholders, plural forms, and inline markup preserved; registered in `frontend/src/i18n/index.ts` and selectable as "Español" in the language picker. The parity checker auto-discovers the file — `frontend/scripts/check-i18n-parity.mjs` gained an `ES_COGNATES` allow-list for genuine Spanish cognates and brand/format tokens. Brings the supported-language count to 9 (en / de / es / fr / it / ja / pt-BR / zh-CN / zh-TW). Parity green, frontend build clean. - **Currency: Belize Dollars (BZD) added to the Settings → Cost currency dropdown (#1454, requested by @PLGuerraDesigns)** — Reporter accurately tracks 3D-printing filament costs in his local currency and BZD wasn't selectable, forcing a manual 2:1 mental conversion from USD. Added `BZD: 'BZ$'` to `frontend/src/utils/currency.ts` next to MXN (Americas dollar-prefix grouping); `getCurrencySymbol('BZD')` returns `'BZ$'` and the SUPPORTED_CURRENCIES list now has 30 entries. Unit test added in `frontend/src/__tests__/utils/currency.test.ts` covering the symbol lookup and presence in SUPPORTED_CURRENCIES; entry-count assertion bumped to 30 so any future additions/removals are caught immediately. 14 currency tests green; frontend build clean. - **Connection Diagnostic — self-service triage for "printer won't connect / won't print"** — A triage review of recently-closed issues found roughly a third were user-side setup errors (printer not in LAN developer mode, blocked ports, Docker bridge networking, wrong access code, printer on a different subnet), each costing a multi-round-trip "enable debug logging → build a support bundle → upload it" exchange. A new diagnostic (`backend/app/services/printer_diagnostic.py`) runs those checks automatically: TCP reachability of MQTT 8883 / FTPS 990 / RTSPS 322, LAN developer mode, Docker network mode, printer/host subnet match, and MQTT credential class — each returning a pass / fail / warn / skip status with a localized plain-language fix. Exposed via `GET /printers/{id}/diagnostic` (saved printer) and `POST /printers/diagnostic` (pre-save Add-Printer flow), and surfaced as a one-click "Run diagnostic" from the printer card actions menu (plus a quick button on the card when a printer is offline), the Add-Printer dialog, and a new Connection Diagnostic section on the System page. The in-app bug reporter scans configured printers when the report form opens and always shows the result — a healthy confirmation when nothing's wrong, or the detected problem and its fix inline — so setup mistakes get self-resolved instead of becoming GitHub issues. The GitHub `config.yml` troubleshooting link was repointed from the wiki source repo to the rendered troubleshooting page. Backend service unit tests (15) and frontend modal tests (3) added; all diagnostic strings translated across the 8 locales. Backend ruff clean, frontend build clean, i18n parity green. ### Changed - **Settings → SpoolBuddy: CPU load tile added to the device card** — The SpoolBuddy daemon's heartbeat already reports `load_avg` (1/5/15 min) and `cpu_count` via `system_stats` (see `spoolbuddy/daemon/system_stats.py`), but the device card on the Bambuddy SpoolBuddy settings only rendered CPU temp / memory / disk / system uptime. Adds a fifth tile next to CPU temp showing the 1-minute load average alongside core count and a percent-of-cores readout — for a 4-core Pi: `1.20 / 4 (30%)`. Falls back to a bare load number when `cpu_count` isn't reported, and the tile is hidden entirely when the daemon doesn't emit `load_avg` (older builds). Useful for spotting the "I2C/SPI stuck after idle overnight" pattern early — sustained high load before the bus dies points at runaway daemon work rather than a kernel hang. Translated across all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW). Frontend build clean, i18n parity green. - **Virtual printer: setup diagnostic + one-click slicer-certificate export** — Two recurring virtual-printer support pains, addressed on the Virtual Printers settings page. **(1) Setup check** — a new stethoscope action on each VP card runs `GET /virtual-printers/{id}/diagnostic` and shows a pass/fail/warn/skip checklist: VP enabled, services running, bind interface still exists, access code set, target printer (proxy mode), and — decisively — a live TCP probe of the FTP/MQTT/discovery ports on the bind IP. The manager swallows per-service start errors (`run_with_logging`), so a service object can exist while nothing is actually listening; probing the bind IP from outside is the only reliable signal, and it catches the common "VP doesn't show up in the slicer" bind-IP-conflict and stale-interface cases. New `backend/app/services/virtual_printer/diagnostic.py` + `VPDiagnosticResult` schema + `VirtualPrinterDiagnosticModal.tsx`. **(2) Slicer certificate** — virtual printers present a TLS cert signed by a shared CA the slicer must trust; until now users had to `docker exec` in and `cat bbl_ca.crt` to get it. A new "Slicer certificate" row on the Virtual Printers settings card (alongside the Archive name source toggle) offers Copy and Download (`bambuddy-virtual-printer-ca.crt`) plus the CA's SHA-256 fingerprint, served by `GET /virtual-printers/ca-certificate` — only the public certificate, never the CA private key. The CA is generated on demand so the button works before the first VP is enabled. Copy uses a non-secure-context fallback (Bambuddy is usually on plain-HTTP LAN), extracted into a shared `utils/clipboard.ts`. 9 backend diagnostic/CA unit tests + 4 route integration tests + 6 frontend tests (diagnostic modal, clipboard helpers); all `vpDiagnostic.*` / `virtualPrinter.caCert.*` strings translated across the 9 locales. Backend ruff clean, frontend build clean, i18n parity green. - **Bug-report panel: connection diagnostic no longer overflows on multi-printer setups** — The "Report a Bug" panel scans every configured printer on open and surfaces connection problems inline so users can self-fix before filing. The first cut rendered a full ~6-row checklist for *each* problem printer stacked vertically; a user with many printers all reporting issues pushed the description box, screenshot uploader and Submit button far below the fold in the `max-w-md` / `max-h-[80vh]` panel. The diagnostic section is now a compact summary — one line ("N of M printers have connection issues") followed by the affected printers as collapsed rows (healthy printers count toward M but render no detail). Each row expands on demand to that printer's full checklist via the shared `Collapsible` widget; when exactly one printer has problems the row is auto-expanded since that's the case where inline detail is wanted with no extra click. The panel now stays a fixed ~3 lines plus one row per affected printer regardless of fleet size, keeping the report form reachable. Healthy-fleet confirmation line is unchanged. New `bugReport.diagnosticSummary` key (with `{{problems}}`/`{{total}}`) replaces the static `diagnosticHeading`; `diagnosticIntro` reworded to be printer-count-neutral and point at the expand affordance — both translated across all 9 locales. 2 new tests in `BugReportBubble.test.tsx` (multiple problems stay collapsed and expand on click; a single problem auto-expands); 11 tests green; frontend build clean; i18n parity holds at 4903 keys × 9 locales. - **Color Catalog sync now identifies itself as Bambuddy to filamentcolors.xyz** — The FilamentColors.xyz sync client in `inventory.py` created its `httpx.AsyncClient` with no `User-Agent`, so it leaked httpx's default `python-httpx/x.y` string — the only outbound client that did (`bambu_cloud`, `makerworld`, `firmware_check` all send the honest `Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)`). It now sends the same honest UA, consistent with the rest of the codebase. Surfaced while investigating #1456 (a Cloudflare `403` on the sync that turned out to be the reporter's network/IP reputation, not Bambuddy — the UA leak was a separate inconsistency found in passing, and this change does not by itself resolve a Cloudflare IP block). - **Filament inventory: grouped rows now show group totals (#1368, requested by a user)** — With "Group similar" enabled, the collapsed group row showed the values of a single member (the first spool) — so a group of five 1 kg spools displayed "1000 g" instead of the 5 kg it actually held. The group header now aggregates across all members: the table view's Label, Net, Gross, Used and Remaining columns and the grid card's weight figure show group totals, while identity columns (Material, Brand, Colour) and the Cost/kg rate stay per-spool-correct. Per-spool-only fields with no meaningful total (dates, location, note, tag ID) keep showing the representative member's value; the expanded individual rows are unchanged. New `aggregateGroupSpool` helper in `frontend/src/utils/inventoryGrouping.ts` with 4 unit tests. Frontend-only — all data was already in the spool list. — Previous behaviour disabled the Slice button whenever the source 3MF's bound printer model didn't match the user's picked printer profile, on the theory that the slicer CLI "cannot re-slice a 3MF for a different printer" and would silently fall back to embedded settings to produce a wrong-printer file. Step 0 empirical test on 2026-05-20 disproved that: an 18-color H2D-bound `Trent900.3mf` sliced via the X1C bundle (`POST /slice` with `bundle=cb…X1C, printerName=# Bambu Lab X1 Carbon 0.4 nozzle`) produced 2.3 MB of genuinely X1C-compatible G-code in 1.8 s — `printer_model` overridden to `Bambu Lab X1 Carbon`, `printable_area` to 256×256 (X1C bed, not H2D's 350×320), `printable_height` 250 (vs 325), `bed_exclude_area` populated with X1C's 18×28 corner zone, `nozzle_diameter` single 0.4 (vs H2D's dual `0.4,0.4`), and the full X1C `machine_start_gcode` sequence baked in. The sidecar takes printer / process / first-N filament names from the picked bundle and only inherits embedded values for unused trailing slots — bed size, kinematics, start sequence all come from the target. **Behavioural change**: dropped `!printerMismatch` from the SliceModal `isReady` predicate so the Slice button stays enabled when models differ. The amber banner was first softened to an info message, then removed entirely — re-slicing across printers is now just a normal slice, the picker UI already shows which printer was picked, no second confirmation needed. **Dead-code removal (same drop)**: with no banner, the `source_printer_model` field on the `/library/files/{id}/plates` and `/archives/{id}/plates` responses had zero consumers; the `extract_source_printer_model_from_3mf` helper in `threemf_tools.py` (which opened the 3MF zip and read `Metadata/project_settings.config` on every plate request) had zero callers. Removed both response keys, both backend extractions, both `threemf_tools` imports, the helper itself, its 6 unit tests, the `source_printer_model` field from `frontend/src/types/plates.ts` (PlateMetadata + LibraryFilePlatesResponse), and 2 obsolete SliceModal tests that exercised the now-impossible matched-printer / legacy-archive paths. **i18n discipline cleanup (same drop, per [[feedback_no_followups]] + [[feedback_translate_dont_fallback]])**: every t() callsite in SliceModal.tsx had an inline English `defaultValue:` or positional-second-arg English fallback — 22 sites in total. With 8 locales shipped, those fallbacks are dead weight at best, and an actual i18n-violation when the key is missing because non-English users would silently see English. Audit found 3 keys (`slice.bundle`, `slice.bundleNone`, `slice.bundleAllRequired`) that had **no** corresponding entry in any locale file — they were being served from the inline English fallback exclusively, meaning every non-English user was already seeing those three labels in English. Added all 3 to all 8 locales with real translations, then stripped the English fallback from every t() call in SliceModal.tsx. The `slice.printerMismatch` key was removed from all 8 locales (banner is gone). **Why this matters**: a recurring pain point for users importing MakerWorld project files where the original creator's printer often differs from the user's; previously they had to round-trip through BambuStudio's "convert project" flow to re-export. Now Bambuddy re-slices in-place with no UI friction. **Tests**: the existing SliceModal "shows mismatch warning AND disables Slice" test was rewritten to assert "does not surface any cross-printer banner AND keeps Slice enabled when models differ" (regression guard against the gate being re-added); 2 obsolete tests deleted. 32 SliceModal tests green (was 34, -2 dead tests); 49 threemf_tools tests green (was 55, -6 helper tests); 24 plates-route tests green; frontend build clean; backend ruff clean; i18n parity check passes 4858 keys × 8 locales (net +2 vs pre-fix: +3 bundle keys, -1 printerMismatch). ### Security - **idna: bump to `>=3.15` to clear CVE-2026-45409 (ReDoS in `idna.encode()` with crafted Unicode payloads, e.g. `"٠" * N` or `"・" * N + "漢"`)** — Transitive dep pulled in by anyio / httpx / requests / yarl; not directly pinned, which is why it lingered at 3.13. Added an explicit `idna>=3.15` floor in `requirements.txt` between Authentication and HTTP-client blocks with a comment explaining why it's pinned (so a future downstream loosening doesn't silently downgrade us). Verified via `pip-audit` clean post-upgrade. - **starlette: bump floor to `>=1.0.1` to clear PYSEC-2026-161** — `starlette` is a transitive dep pulled in by fastapi, whose range still admits the vulnerable 1.0.0 build, so a fresh `pip install` would silently pick it back up. Added an explicit `starlette>=1.0.1` floor in `requirements.txt` under the urllib3 pin with a why-comment matching the same pattern as the idna/urllib3 entries. Release-notes reviewed for both 1.0.1 (single fix: ignore malformed `Host` header when constructing `request.url`) and 1.1.0 (the resolver actually picked up 1.1.0): three behavioural changes — `FileResponse` falls back to `application/octet-stream` when `mimetypes.guess_type()` can't resolve (Bambuddy has 2 `FileResponse` calls without explicit `media_type`, both serving `index.html` where guess_type still resolves to `text/html`, plus custom-icon serving in `external_links.py:261` where the new fallback is a security improvement), `HTTPEndpoint` only dispatches standard HTTP verbs (`grep` found zero `HTTPEndpoint` usages in Bambuddy — pure FastAPI router code), `StaticFiles.lookup_path` rejects absolute paths in *requests* (the 4 mounts in `main.py:5503-5525` pass absolute *base directories* to the constructor, which is unaffected — only path-traversal-style request paths get rejected). Full backend test suite green (5300/5301; the 1 failure is a pre-existing `-n 30` parallelism flake unrelated to starlette and passes in isolation). Verified clean via `pip-audit` post-upgrade. - **PyJWT CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): permanently ignored in pip-audit** — Advisory is disputed by the PyJWT maintainers, with the advisory description literally noting *"this is disputed by the Supplier because the key length is chosen by the application that uses the library."* `fix_versions=[]` on the advisory confirms no PyJWT patch exists or will exist. Bambuddy is not affected: `backend/app/core/auth.py:184` auto-generates secrets via `secrets.token_urlsafe(64)` (~86 chars of entropy, far above any sane minimum) and the file-loaded path at `:177` rejects secrets shorter than 32 chars. Added a permanent `--ignore-vuln CVE-2025-45768` to `.github/workflows/security.yml` with an inline comment citing the file:line evidence so a future maintainer reviewing the ignore list sees why it's load-bearing. Also dropped the stale `--ignore-vuln CVE-2026-4539` for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified: `pip-audit --ignore-vuln CVE-2025-45768` alone reports clean). ### Fixed - **Support bundle + bug-report submission now include the live diagnostic snapshot** — Three diagnostics (Connection Diagnostic per printer, Virtual Printer Setup Diagnostic per enabled VP, Log Health Scanner) have shipped on the System page and inline in the bug-report bubble since 6bc6a1d6 / e222a0ef / ed31b8f4, but the results were only ever shown to the *user* — never persisted into the downloadable support ZIP or the submitted GitHub issue. A report saying "looks broken in Bambuddy" arrived with no actionable signal beyond raw logs. **Fix**: new `services/diagnostic_snapshot.collect_diagnostic_snapshot` runs all three concurrently with an outer per-probe 15 s wall-clock cap (so a hung interface adds at most ~15 s to bundle generation regardless of fleet size — `asyncio.gather`, total ≈ max(per-cap) not sum). Fail-soft per probe: a crash inside one printer's check emits `{"printer_id": N, "error": "..."}` for that entry rather than nuking the whole snapshot — partial result beats a 500. Wired into `_collect_support_info()` so both flows (`POST /support/bundle` and `POST /bug-report/submit` via `support_info=...`) pick up the new `diagnostics` top-level key without their own changes. **Private-data sanitization** — the diagnostic schemas embed raw IPv4 in three places (`PrinterDiagnosticResult.ip_address`, network-mode check's `params.{printer_ip, host_ip}`, VP diagnostic's `params.bind_ip`), and the snapshot adds printer names. None of those should leak. The snapshot now runs a recursive sanitizer on the full result tree before returning: known DB-listed values (printer name, IP, serial, access code) get the same `[PRINTER]/[IP]/[SERIAL]/[ACCESS_CODE]` labels the log sanitizer already applies (via the shared `collect_sensitive_strings`), and an IPv4-regex fallback catches IPs the DB doesn't know about — most importantly the Bambuddy host IP returned by `_get_host_ip()` and any VP `bind_ip` the user picked at setup. Live-DB smoke test confirms zero raw IPv4 instances in the serialized snapshot output. **Progress indicators**: the bubble's "submitting" view and the System page's Download button now render a static four-line checklist showing what's running (printer connectivity → VP setup → log scan → submit/build ZIP) — communicates the longer wait honestly without faking server-side phase progress we can't actually track. **Tests**: 6 new in `test_diagnostic_snapshot.py` — empty-input shape stable, per-printer / per-VP result coverage, fail-soft on a single-probe crash, `timed_out` marker when a probe exceeds the per-probe cap (test patches the cap to 0.05 s), end-to-end IP sanitization across all five field shapes (top-level `ip_address`, `printer_ip`, `host_ip`, `bind_ip`, plus IPs embedded in log-health sample lines) with a final regex sweep over the JSON-serialized result asserting zero raw IPv4 escapes, concurrent execution proof (4 × 0.2 s probes complete in < 0.5 s, would be 0.8 s sequential). Existing 27 BugReportBubble + SystemInfoPage frontend tests still pass; 9-locale i18n parity check clean (4993 leaves per locale, 9 new keys added with real translations everywhere — no English fallback). Backend ruff clean. - **"Prefer Lowest Remaining Filament" now uses Bambuddy's inventory weight, not just the printer's RFID counter (#1508, reported by @kleinwareio)** — Reporter has an inventory spool cloned to slot 1 and the original (much further used) in slot 4 of the same P1S AMS, with the preference enabled, and the dispatch consistently picked slot 1 (the fresh clone) instead of slot 4 (the original they wanted to finish first). Root cause is the `prefer_lowest` sort in `_match_filaments_to_slots` (`print_scheduler.py`): the sort key reads `f.get("remain", -1)` straight out of `_build_loaded_filaments`, which sources it from MQTT AMS `tray.remain` — the printer firmware's own RFID-decremented value. Two problems with that signal: (a) it's only populated for Bambu RFID spools, so every non-RFID / 3rd-party / user-loaded tray reports `-1` and gets clamped to a sentinel — multiple non-RFID spools then tie in the sort and Python's stable sort collapses to AMS-slot insertion order, so slot 1 always wins; (b) even when set, it's the *printer's* counter, not Bambuddy's `label_weight - weight_used` (internal mode) or Spoolman's `remaining_weight` (Spoolman mode) — the two diverge any time the user re-spools, swaps cardboard, or runs a print outside Bambuddy. The reporter is on internal-inventory mode with non-RFID spools — both failure modes apply, hence slot 1 every time. **Fix**: when a slot is bound to a Bambuddy / Spoolman spool, that inventory record's remaining weight becomes the sort signal. New async helper `_build_inventory_remain_overrides(db, printer_id, loaded)` returns `{global_tray_id: remaining_grams}` for slots with an assignment — internal mode joins `SpoolAssignment` → `Spool` once per dispatch, Spoolman mode joins `SpoolmanSlotAssignment` then fetches each spool through the existing `_spoolman_remaining_grams` (shared with `filament_deficit.py`, parity rule per [[feedback_inventory_modes_parity]]). The new `_prefer_lowest_sort_key` consumes that map alongside the legacy MQTT field with a **two-tier** comparison: inventory-tracked spools always sort BEFORE MQTT-only spools, then ascending by remaining within each tier, then ascending by `ams_id * 4 + tray_id` as the deterministic slot tie-breaker. The tier flag dominates so we never compare grams (inventory) against percent (MQTT) — no unit-conversion contortions. MQTT-only behaviour is preserved exactly: `remain = -1` still maps to the 101 sentinel and slot order still decides on ties, so users who haven't bound any spools see no change. External / VT tray slots are skipped (tracked separately from AMS bindings). Lookup runs only when `prefer_lowest_filament` is enabled — no extra DB hit for users who don't use the preference. **Tests**: 6 new in `TestPreferLowestInventoryOverride` in `test_scheduler_ams_mapping.py` (inventory override beats MQTT remain — the literal reporter scenario with 950 g clone vs 50 g original; zero-grams still sorts first within its tier; inventory tier beats MQTT tier regardless of value; tied inventory grams break to lower slot; no-override falls through to MQTT — regression guard for un-tracked spools; legacy `remain = -1` still sentinel-sorts last when override map is None) + 7 new in `test_scheduler_inventory_remain.py` covering `_build_inventory_remain_overrides` directly (internal mode returns label_weight − weight_used per bound slot; external slots skipped; empty loaded short-circuits; over-consumed spool clamps to 0 g; unbound slots absent from map; Spoolman mode uses `_spoolman_remaining_grams` for parity; Spoolman unreachability silently omits that slot). 102 scheduler + inventory tests green; backend ruff clean. - **X1/H2/P2 live camera no longer fails with `Address already in use` on transitional ffmpeg builds (#1504, reported by @rage03usa, confirmed by @PawseHaxor)** — On a native Ubuntu install with the Jammy-era system ffmpeg, the RTSP live-view path retried indefinitely with `Unable to open RTSP for listening … Address already in use`. Snapshots, the camera diagnostic, and OrcaSlicer all kept working — only live view was broken. Cause: the ffmpeg argv built in `backend/app/api/routes/camera.py` (added in 530a7a46 as part of an RTSP-stability bundle) passed `-timeout 30000000`. That ffmpeg version *deprecated* the original `-timeout` (socket I/O microseconds) and repurposed the name to mean the *RTSP listen-mode incoming-connection timeout* — any non-zero value **implies `-listen`**. ffmpeg then flipped into RTSP server mode and tried to bind the same localhost port Bambuddy's TLS proxy was already listening on, hence EADDRINUSE on every retry (the odd-port pattern @PawseHaxor noticed is coincidence — the ephemeral allocator just picked odd values that run). The reporter's own workaround (drop the option) works but silently loses the socket-level read timeout, so a hung TLS handshake would block past the OS TCP timeout instead of failing fast into the existing reconnect loop. **Why this can't be a one-line literal swap**: ffmpeg has shipped *three* arrangements of this option over time and Bambuddy supports the full range. Pre-deprecation builds: `-timeout` is the socket I/O timeout. Transitional builds (~late-4.x, what the reporter is on): `-timeout` is the broken listen-mode option, `-stimeout` is the replacement. **Modern ffmpeg (5.x / 6.x / 7.x — current Debian 13, Ubuntu 24.04, current Homebrew)**: `-stimeout` was removed entirely and `-timeout` is back to socket I/O. So both literals regress one half of the install base. **Fix**: a new `rtsp_socket_timeout_flag()` helper in `backend/app/services/camera.py` probes `ffmpeg -h demuxer=rtsp` once at first use and picks `-stimeout` when ffmpeg advertises it (transitional window) or `-timeout` otherwise (modern + very old). The result is cached for the process lifetime — ffmpeg won't swap mid-run. The function returns the option name without a leading dash so callers prepend it themselves (no empty-flag formatting bug). Wired into both RTSP ffmpeg call sites — `routes/camera.py` (printer camera) and `services/external_camera.py` (external RTSP) — in lockstep, same TLS-proxy + ffmpeg pattern, same regression. The reporter had tried `-listen_timeout` (doesn't help — we *don't* want listen mode) and `-rw_timeout` (AVIO-level, RTSP demuxer doesn't honour it on its control socket), but no manual swap could be correct for both transitional and modern installs simultaneously. **Tests**: 8 in `test_ffmpeg_rtsp_timeout_flag.py` — 6 unit tests for the probe (picks `-stimeout` when advertised, falls back to `-timeout` on modern, defaults to `-timeout` when ffmpeg missing or probe raises, caches across calls, substring-match guard against false-positives on `-listen_timeout`), 2 parametrised regression guards against either RTSP ffmpeg argv re-hard-coding a literal flag instead of consuming the probe. 37 (probe + existing external-camera) tests green; backend ruff clean. - **SliceModal: process / filament dropdowns now filter by nozzle diameter too, not just printer model (#1325 follow-up #2, reported by @IndividualGhost1905)** — With the @BBL name fallback in place, the reporter saw that an X2D 0.4 selection still mixed 0.2 / 0.6 / 0.8 nozzle process variants into the main list. The fallback's regex stripped any trailing ` nozzle` suffix from both sides before comparing, so `"Bambu Lab X2D 0.4 nozzle"` and `"0.40mm Strength @BBL X2D 0.8 nozzle"` both reduced to `"X2D"` and matched. The bundle path was already nozzle-correct (a `.bbscfg` is scoped to one printer-preset-name including its nozzle, so the bundle-side exact-match was nozzle-aware); only the name fallback needed fixing. **Fix**: `extractPrinterPresetModel` and `extractBblToken` now each return `{ model, nozzle }`. The nozzle is the parsed string ("0.4" / "0.6" / etc.) or `null` when the name has no suffix. `classifyByBambuName` treats a `null` process nozzle as `"0.4"` — Bambu's convention is to omit the suffix on 0.4 (the default) and include it for 0.2 / 0.6 / 0.8, exactly as the reporter described. Both `model` and `nozzle` must compare equal for a `'match'`; differing nozzles fall into the existing "Other printers" group, no new group label needed. If the selected printer preset name has no parseable nozzle (non-Bambu / hand-typed), the matcher degrades to model-only — Bambu printer presets always include nozzle in practice, so this is defensive. **Tests**: 9 new in `slicerPrinterMatch.test.ts` covering the matrix (0.4 printer vs no-suffix / 0.6 / 0.8 process; 0.6 printer vs 0.6 / no-suffix-=-0.4; explicit 0.4-suffix-on-process still matches 0.4 printer; same rule on filament presets; wrong-model dominates over matching-nozzle; no-nozzle printer name degrades to model-only); one existing test reframed (the case that previously asserted a 0.6-nozzle process matched a 0.4 printer — the exact bug — now asserts mismatch). 46 slicerPrinterMatch + 34 SliceModal tests green; frontend build clean. - **Timelapse now attaches to the archive after a backend restart mid-print (#1485 follow-up, reported by @pwostran)** — With the duplicate-archive fix from #1485 in place, a restart mid-print stopped creating ghosts — but the resulting archive came back without its timelapse video (only the finish snapshot was attached). Cause is a side-effect of the #1304 first-push guard: on the first MQTT push after Bambuddy starts (`_previous_gcode_state = None`), `is_new_print` is deliberately False so `on_print_start` doesn't fire — which prevents duplicate archive creation **but also** prevents the timelapse-baseline capture, since both live behind the same callback. At PRINT COMPLETE, `_scan_for_timelapse_with_retries` finds an empty `_timelapse_baselines` for the printer and falls into the "take baseline now" fallback in `main.py`. By that point the printer has already uploaded the in-flight MP4, so the snapshot includes it. Every retry then reports "N files found / no new files since baseline" and the scan gives up. The reporter's support bundle is the smoking gun — pre-reboot baseline of 7 files, post-reboot fallback baseline of 8 files (including the just-uploaded one), 4 retries all unable to see the diff. **Fix**: `bambu_mqtt.py` now fires a sibling `on_print_running_observed` callback inside the "Now tracking RUNNING state" branch when the first-push guard suppresses `on_print_start`. `main.py` wires it to a thin handler that fetches the printer row from DB and calls the existing `_capture_timelapse_baseline_at_start`. The callback only fires the first time we observe RUNNING per session (gated on the same `not self._was_running` branch the timelapse-flag restore already lives in), so a normal print start path is unaffected. The handler is also idempotent: if a baseline already exists for that printer, it returns without touching it. Safe because the printer doesn't upload the timelapse until *after* PRINT COMPLETE, so a baseline captured any time during the in-flight print is still pre-upload — no narrow window. The plumbing (`set_print_running_observed_callback` setter, in-`connect_printer` wrapper, constructor pass-through) mirrors the existing `on_print_start` / `on_print_complete` callback chain in `printer_manager.py`. **Tests**: 7 new in `TestPrintRunningObservedCallback` in `test_bambu_mqtt.py` (fires on first RUNNING after startup, doesn't double up with `on_print_start`, fires only once per session, skips on non-RUNNING / missing file / no-callback-set, payload shape mirrors `on_print_start`); 3 new in a dedicated `test_timelapse_baseline_restart_recovery.py` (handler captures the printer's existing-videos snapshot into `_timelapse_baselines`, skips when a baseline already exists, skips when the printer row was deleted between push and handler). 336 MQTT + print-start + timelapse tests green; backend ruff clean. - **SliceModal: process / filament dropdowns now filter for users who haven't uploaded slicer bundles (#1325 follow-up, reported by @IndividualGhost1905)** — The original #1325 fix replaced a stale hardcoded `@BBL ` allow-list with bundle-based compatibility: a process / filament preset was classified against the selected printer by consulting the user's uploaded Slicer Bundles (.bbscfg). That works perfectly for users who have uploaded bundles for every printer their cloud catalogue covers — and silently no-ops for everyone else: every cloud preset resolves to `'unknown'`, nothing moves into "Other printers", and the dropdown looks identical to the pre-fix state. **Fix**: restored the `@BBL ` name fallback as a third tier *below* the bundle path, but with the token-to-printer mapping driven by **the backend's canonical `PRINTER_MODEL_MAP`** (`backend/app/utils/printer_models.py`) instead of a duplicated frontend table. A new `GET /api/v1/slicer/printer-models` route ships the mapping unmodified; `slicerPrinterMatch.buildCompatibilityIndex` accepts it as a second arg, inverts it into a short-code → display-fragment table (`X1C` → `X1 Carbon`, `P2S` → `P2S`, `A1 Mini` → `A1 mini`, …), and `presetCompatibility` uses it only after `compatible_printers` and the bundle index have already returned `'unknown'`. The match is case- and whitespace-insensitive (`"A1 mini"`, `"A1 Mini"` and `"a1mini"` all compare equal). When the registry doesn't list a token, the matcher falls back to comparing the raw token against the printer-preset model fragment — so a brand-new "Q1" printer with `@BBL Q1`-tagged presets matches without any code change. Adding a new model only requires updating the existing backend `PRINTER_MODEL_MAP` (already the single source of truth for `is_dual_nozzle_model`, the rod-type/ethernet registries, and 3MF metadata normalisation) — no frontend table to keep in sync. **Tests**: 2 new in `test_slicer_presets.py` (`/printer-models` returns the full `PRINTER_MODEL_MAP`; the route hands back a copy, not the live module dict); the existing 25 `slicerPrinterMatch.test.ts` cases were extended to 36 covering: registry-driven X1C vs X1 Carbon match, A1 vs A1 mini disambiguation, H2D vs H2D Pro disambiguation, the previously-missing P2S / H2C / H2S / X2D, raw-token fallback for unregistered models, graceful degradation when the registry fetch hasn't resolved yet, the `compatible_printers`-wins-over-name rule, and the bundle-wins-over-name rule. 38 slicer-presets + 36 slicerPrinterMatch tests green; backend ruff clean; frontend build clean. - **Cloudflare-fronted Bambuddy no longer needs an `unsafe-inline` override to load (#1460 follow-up, reported by @Soopahfly)** — A Bambuddy instance behind Cloudflare logged an inline-script CSP violation on every page load: Cloudflare's bot-detection script (`/cdn-cgi/challenge-platform/scripts/jsd/main.js`) is injected into the HTML on the edge with a hash that changes per request, so it can never be allowlisted by `script-src` hash. The contributor's workaround was to relax `script-src` to `'unsafe-inline'` in their Nginx Proxy Manager — which works but defeats most of the CSP. **Fix**: the SPA CSP now stamps a fresh per-request **nonce** into `script-src` (`'self' 'nonce-'`). Per [Cloudflare's documented behaviour](https://developers.cloudflare.com/cloudflare-challenges/challenge-types/javascript-detections/#if-you-have-a-content-security-policy-csp), when a nonce is present in the CSP header Cloudflare clones the same nonce onto its injected `