github maziggy/bambuddy v0.2.5b1-daily.20260620
Daily Beta Build v0.2.5b1-daily.20260620

pre-release3 hours ago

Note

This is a daily beta build (2026-06-20). It contains the latest fixes and improvements but may have undiscovered issues.

Docker users: Update by pulling the new image:

docker pull ghcr.io/maziggy/bambuddy:daily

or

docker pull maziggy/bambuddy:daily


**Tip:** Use [Watchtower](https://containrrr.dev/watchtower/) to automatically update when new daily builds are pushed.

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. `vitest4.1.8` already accepts vite 8 in its peer range (`^6 || ^7 || ^8`); no test-runner bump required. Node: vite 8 requires `^20.19.0 || >=22.12.0`; CI Node 20.x line satisfies this. What this is NOT: plugin-react v6 — that line requires `babel-plugin-react-compiler` + `rolldown/plugin-babel` as peers and is a separate scope. `npm run build`, `npm run lint`, `npx vitest run` all clean; `npm audit` clean.
  • Frontend dependency bumps — Routine version updates across the runtime, build, and test dependency surface. Runtime: 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-linktiptap/pmprosemirror-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 nilgit 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 floorNOT 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

  • QR code on API-key creation that encodes server URL + key together (#1677, contributed by bambuman) — The "API Key Created Successfully" panel gets a new QR code button next to Dismiss. Clicking it opens a modal showing a single QR encoding the Bambuddy base URL and the freshly-created API key together, so a mobile client (e.g. the contributor's BambuMan NFC inventory app, or any future Bambuddy-aware app) can scan once to configure both — no copy-paste of the long, shown-only-once secret. Payload contract (versioned): bambuddy://config?v=1&url=<encodeURIComponent(baseUrl)>&key=<encodeURIComponent(apiKey)>. v=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_<user_id> 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=<n> (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=<csv> 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=<N> 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=<bool> 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": <bool>, "sequence_id": "20000"}} was verified by capturing BambuStudio's own command on the request topic with a temporary outbound diagnostic logger — single field at a time, never bundled with 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<global_tray_id, grams> 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<number, number> param — undefined preserves pre-#1766 behaviour for any caller that hasn't wired it in. The banding tie-break alignment is load-bearing on its own: an earlier draft 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<global_tray_id, grams> the backend would compute. Internal AND Spoolman modes both work uniformly via the existing scheduler helper's branch — external / VT slots excluded, negative grams clamped. Frontend fetches per selected printer 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_VIEWcan_read_status, MAKERWORLD_IMPORTcan_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<LocalPreset[]>(['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_managerprinter_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 <plate> 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 <filament used_g="..."> entries for the actual grams, read the plate's <metadata key="prediction"> 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.

Don't miss a new bambuddy release

NewReleases is sending notifications on new releases.