Note
This is a daily beta build (2026-06-19). It contains the latest fixes and improvements but may have undiscovered issues.
Docker users: Update by pulling the new image:
docker pull ghcr.io/maziggy/bambuddy:daily
or
docker pull maziggy/bambuddy:daily
**Tip:** Use [Watchtower](https://containrrr.dev/watchtower/) to automatically update when new daily builds are pushed.
Security
- Vite 7 → 8 major bump — Bambuddy's frontend now builds with Vite 8 (
^7.3.2→^8.0.16) and the matching plugin-react release (vitejs/plugin-react^5.1.1→^5.2.0). Headline architectural change: Vite 8 swaps Rollup for Rolldown as the default bundler — same plugin contract, Rust-backed core, slightly different chunk layout / output bytes (no functional regression). The bump also lifts the transitiveesbuildfloor to 0.28.1, which closes the last open advisory in the audit chain. Bambuddy-side surface audited:vite.config.tsuses only stable contracts that survived the v8 cut —defineConfig, theConnecttype, the customserveGcodeViewerconfigureServermiddleware plugin (proxies/gcode-viewer/*to the repo's siblinggcode_viewer/directory in dev), theserver.proxywith WebSocket upgrade for/api/v1/ws,build.outDir/emptyOutDir/chunkSizeWarningLimit, andresolve.aliasfor ``.base: '/'regression guard from #1221 is unaffected. No SSR, no library mode, no CSS preprocessors, no exotic plugins. `vitest4.1.8` already accepts vite 8 in its peer range (`^6 || ^7 || ^8`); no test-runner bump required. Node: vite 8 requires `^20.19.0 || >=22.12.0`; CI Node 20.x line satisfies this. What this is NOT: plugin-react v6 — that line requires `babel-plugin-react-compiler` + `rolldown/plugin-babel` as peers and is a separate scope. `npm run build`, `npm run lint`, `npx vitest run` all clean; `npm audit` clean. - Frontend dependency bumps — Routine version updates across the runtime, build, and test dependency surface. Runtime:
dompurify3.4.0 → 3.4.10.package.jsonfloor raised from^3.4.0to^3.4.10so fresh installs cannot land on the deprecated 3.4.4 release. Three call sites use string-output sanitisation (frontend/src/pages/MakerworldPage.tsx,frontend/src/pages/ProjectDetailPage.tsx,frontend/src/components/ProjectPageModal.tsx); release notes 3.4.1 → 3.4.10 reviewed for behavioural changes — 3.4.4 widened the default allow-list withselectedcontent+command+commandfor(all valid modern HTML, harmless for our two default-allow-list call sites), andProjectPageModalis unaffected anyway because it sets an explicitALLOWED_TAGS/ALLOWED_ATTRwhitelist. Build / lint / test tooling (transitive, dev-only):babel/core7.29.0 → 7.29.7 (pulled byvitejs/plugin-reactandeslint-plugin-react-hooks),vite7.3.2 → 7.3.5,markdown-it14.1.1 → 14.2.0 (pulled bytiptap/extension-link→tiptap/pm→prosemirror-markdown; Bambuddy never callsmarkdown-it.renderdirectly so the change is transparent),js-yaml4.1.1 → 4.2.0 (pulled byeslint),form-data4.0.5 → 4.0.6 +ws8.20.1 → 8.21.0 (both pulled byjsdomin the test runtime). All bumps inside existing semver ranges exceptdompurify. No source changes required. dompurify3.4.10 → 3.4.11 — Follow-up patch closes a moderate-severity advisory affectingsetConfig()callers: the previous hook clone-guard added in 3.4.7 could be bypassed viasetConfig(), leaving a permanentALLOWED_ATTRpollution that the nextsanitize()call inherited. Bambuddy's exposure is nil —git grep DOMPurify.setConfigreturns zero hits across the entire codebase; all three sanitisation sites (frontend/src/pages/MakerworldPage.tsx,frontend/src/pages/ProjectDetailPage.tsx,frontend/src/components/ProjectPageModal.tsx) callDOMPurify.sanitize(html)orDOMPurify.sanitize(html, {ALLOWED_TAGS, ALLOWED_ATTR})directly, never throughsetConfig(). The bump is taken as defence-in-depth to keep XSS-sensitive surface area current and to silencenpm auditso future audit-fix runs don't auto-bundle unintended changes. Mechanical lockfile bump only: the existing^3.4.10range already permitted 3.4.11, sopackage.jsonis unchanged;package-lock.jsonupdates the resolved URL + integrity hash for the one entry. Verification:npm auditreports 0 vulnerabilities,MakerworldPage.test.tsx's 12 DOMPurify sanitisation cases pass,npm run buildclean.- Backend dependency security floor raises (cryptography / python-multipart / starlette) — pip-audit December 2026 cycle surfaced six advisories across three direct deps; floors in
requirements.txtlifted to the documented fix releases, plus one transitive co-bump for resolver compatibility.cryptography46.0.7 → 48.0.1 floor (resolver picks 49.0.0 within the new floor) — clears GHSA-537c-gmf6-5ccf (non-contiguous Python buffer handling that could overflow on APIs accepting buffer protocol input). Release-notes audit (done before bump): v47.0.0 dropped Python 3.8 + OpenSSL 1.1.x + binary elliptic curves (SECT*) + Camellia + CFB/OFB/CFB8 modes (moved tocryptography_decrepit); v48.0.0 droppedPUBLIC_KEY_TYPES/PRIVATE_KEY_TYPEStype aliases. Bambuddy's grep is clean across every one of those:core/encryption.pyuses Fernet (AES-128-CBC + HMAC),services/spoolbuddy_ssh.pyuses ed25519,services/virtual_printer/certificate.pyuses RSA + x509 + ExtendedKeyUsageOID. Python 3.13 + OpenSSL 3.x on container, so the version-floor bumps are no-ops for us.python-multipart0.0.27 → 0.0.31 floor (resolver picks 0.0.32) — clears CVE-2026-53538/53539/53540 in the multipart parser surface (boundary length capped at 256 bytes, RFC 2231 continuation handling, Content-Length non-negative validation, bounded header field name size before validation). Behavioural changes audited: 0.0.30 stopped recognising RFC 2231/5987 extendedfilename*/name*parameters in incoming bodies — Bambuddy emits these on outgoing Content-Disposition response headers (utils/http.py:17) but doesn't parse them on the request side, and clients that include bothfilename=andfilename*=keep working via the plainfilename=fallback (slight cosmetic difference for non-ASCII filenames in uploads). 0.0.30 also tightened form-urlencoded parsing to treat only&as field separator — every Bambuddy client (browser, BambuStudio, OrcaSlicer) already uses&.starlette1.1.0 → 1.3.1 floor — clears CVE-2026-54282/54283 (FormParsermax_part_size/max_fieldslimits now actually enforced after being declared-but-ignored in earlier releases;StaticFiles.lookup_pathrejects absolute paths;FileResponseclamps oversized suffix range requests;URL.replace()IndexError fix). Critical pre-bump check: the newly-enforcedmax_part_size=1MBdefault would have broken every file upload (UploadFile = File(...)ininventory.py:1127,projects.py:886/1053/1780,library.py:1787,local_presets.py:82,external_links.py:166,local_backup.py) if it applied to file streams. Inspected theMultiPartParser.on_part_datasource: the size check atif self._current_part.file is None:only fires for text form fields, not file streams — so file uploads of arbitrary size still pass through unaffected. Text form bodies in Bambuddy are login credentials and similar small values, well under the 1MB ceiling. Side rename:backend/app/api/routes/mfa.py:470/1364/1428replaces 3 references ofstatus.HTTP_422_UNPROCESSABLE_ENTITY(deprecated in starlette 1.3.x) withHTTP_422_UNPROCESSABLE_CONTENT. Same 422 wire status; silences the 3 deprecation warnings under our own ownership (the two remaining warnings come from FastAPI internals — upstream's to fix).pyopenssl26.0.0 → 26.3.0 floor — NOT a security fix; required because pyOpenSSL<26.3.0capscryptography<47in its install_requires, so without an explicit floor the resolver either downgrades cryptography below the GHSA-537c-gmf6-5ccf fix line or installs an inconsistent pair (pip's resolver warns but proceeds). Bambuddy has no directfrom OpenSSL ...imports — pyOpenSSL is pulled transitively byasyncssh+pywebpush. Verification:pip-auditclean,pip checkclean,ruff check backend/clean, backendpytest -n 306167/6167 in 86.55s. No DB migration, no API surface change, no permission change, no frontend change.
Added
- QR code on API-key creation that encodes server URL + key together (#1677, contributed by bambuman) — The "API Key Created Successfully" panel gets a new QR code button next to Dismiss. Clicking it opens a modal showing a single QR encoding the Bambuddy base URL and the freshly-created API key together, so a mobile client (e.g. the contributor's BambuMan NFC inventory app, or any future Bambuddy-aware app) can scan once to configure both — no copy-paste of the long, shown-only-once secret. Payload contract (versioned):
bambuddy://config?v=1&url=<encodeURIComponent(baseUrl)>&key=<encodeURIComponent(apiKey)>.v=1first so future bumps tov=2have a clean deprecation path; both values URL-encoded so reserved characters in either don't corrupt the parse. The builder lives infrontend/src/utils/apiKeyQr.tsexportingbuildApiKeyQrPayload()+API_KEY_QR_VERSIONso any future mobile-side parser has a stable shared constant to anchor against.baseUrlsource: prefers the configured External URL setting (Settings → Network), falling back towindow.location.originif not set, so the encoded address is reachable from a phone behind a reverse proxy / Docker host. The fallback's failure mode (admin onhttp://localhost:8000without External URL configured → phone can't reach the encoded URL) is unavoidable without exposing a network probe; the warning text in the modal cautions the user generally. Security posture: the QR is generated client-side from the in-memorycreatedAPIKeyReact state — the key is never persisted, never re-fetched (keys are stored hashed at/api/keysPOST and returned in plaintext exactly once), and never round-trips to the server. No download button (intentional contrast with the existingQRCodeModal.tsx, which encodes a public archive URL and does offer download) so the secret can't be saved to disk via the browser's download manager. The "Dismiss" handler now clears bothshowApiKeyQRandcreatedAPIKeyso closing the panel scrubs the plaintext from React state. Modal closes on Escape and backdrop click; an amber warning under the QR reminds the user not to screenshot or share. Component: newfrontend/src/components/ApiKeyQRCodeModal.tsxusingqrcode.react'sQRCodeSVGat 256 px (renders Version 5 / 6 territory for the typical ~120-character payload, comfortably below the alphanumeric capacity). Dependency:qrcode.react ^4.2.0added tofrontend/package.json(+21 KB raw / ~9 KB gzip to the bundle). Existingfrontend/src/components/QRCodeModal.tsxis untouched — different purpose (server-rendered PNG for archive deeplinks), different component, no collision. Tests:frontend/src/__tests__/utils/apiKeyQr.test.tspins the contract — scheme +v=first, exact encoding ofhttps://printer.local+bb_abc123byte-for-byte, special-character round-trip (+,/,=,&, spaces), explicit assertion that the raw unencoded key never leaks into the payload, and aURLSearchParamsround-trip that re-parsesv/url/keyback out and asserts equality with the inputs. 4/4 green. i18n: 4 new keys in thesettings.*namespace (apiKeyQrButton,apiKeyQrTitle,apiKeyQrCaption,apiKeyQrWarning); full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity check green. ESLint clean;npm run buildclean (7,603 kB raw, +21 kB vs dev). No backend change, no permission change, no DB migration. - Centralised sidebar layout + per-page hide toggles (#1673, contributed by EdwardChamberlain) — Sidebar item ordering and visibility move from inline
Layout.tsxstate to a dedicated module so the same persistence rules apply whether the user is reordering with drag-and-drop, toggling an item off, or accepting the admin-pushed default. Newfrontend/src/utils/sidebarLayout.tsowns the localStorage round-trip (sidebarOrder+sidebarHiddenSystemItemskeys), theSIDEBAR_LAYOUT_CHANGED_EVENTcross-tab refresh broadcast, and theisExternalSidebarItemIdhelper that distinguishes the newext-*external link prefix from built-in nav. Hide / show toggle: every built-in sidebar entry (Printers / Inventory / Archives / Queue / Projects / File Manager / Makerworld / Profiles / Maintenance / Statistics — Settings is intentionally non-hideable) now carries an eye icon in the Sidebar settings card; click it to drop that entry from the rendered sidebar. Hidden IDs persist per-user via localStorage so personal taste survives reloads without leaking to other users on a shared install. Re-show by clicking the eye again. The previous drag-to-reorder UX is retired in this PR — the hide list + admin default order cover the same "I never use the Stats page" / "give me Files first" needs without the affordance ambiguity of the rearrange handle. Admin default order: newdefault_sidebar_ordersetting (validated server-side atbackend/app/schemas/settings.py:533+) holds a JSON object{order: string[], hiddenSystemItemIds: string[]}that admins set once from Settings → General → Sidebar (Set Default toggle). On first login per user,Layout.tsx'suseEffectreads the admin default, filters it against the currentdefaultNavItems+ valid external IDs (so a deleted external link or a removed built-in doesn't strand in someone's stored order), applies it locally, and records a per-usersidebarDefaultApplied_<user_id>localStorage flag so the default is one-shot — later user-driven changes aren't clobbered on every login. Settings card:ExternalLinksSettings.tsxis the single source of truth for the Sidebar card (card-sidebar-links) in Settings → General. The header now carries the Set Default toggle (visible only when the caller holdssettings:write), a Reset button (clears bothsidebarOrder+sidebarHiddenSystemItemsto defaults), and the Add Link button (opens the external-link create modal). The body lists every sidebar item — built-in or external — with the eye toggle inline on each row. The header row usesflex-wrapon the outer container and the right-side control group so the Add Link button doesn't overflow the card's right edge when Column 3 sits at its narrowlg:max-w-sm(384px) width. Settings → General reordering (post-merge polish): the Updates card moved to the top of Column 3 (above the new Sidebar card); the Data Management card moved to the bottom of Column 2 (after Library Auto-Purge) so the General tab balances better with the new Sidebar card taking column 3's vertical real estate. Anchor IDscard-updates,card-data,card-sidebar-linksare preserved so deep-links + the in-appregisterSettingsSearchindex still resolve. Layout merge edge case: the PR's refactor ofLayout.tsx::isHiddenaccidentally dropped the dev-side notifications gate (!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || settings?.user_notifications_enabled === false) and itsadvancedAuthStatususeQuery. The merged shape keeps three gates in priority order —hiddenSystemItemIds.includes(id)first (cheapest, explicit user intent), then the array-awarenavPermissionscheck from #1755 (granular*:read_own/*:read_alltiers), then the notifications-specific gate — so a user without advanced auth doesn't suddenly see the Notifications entry. Backend:default_sidebar_ordersettings field accepts both shapes (plain array OR{order, hiddenSystemItemIds}object) for backward compat with installs that saved an array under an earlier draft of this work. Validator rejects anyhiddenSystemItemIdsthat isn't alist[str]with 422. Tests: 17 new backend cases intest_sidebar_settings.pypinning the validator (empty / JSON-array / JSON-object / mixed-types / hostile shapes). Frontend: 5 newLayout.test.tsxcases pinning the hide-toggle behaviour (hidden ID drops the entry, hidden ID for Settings is ignored —settingsis non-hideable, eye-click round-trips through localStorage,SIDEBAR_LAYOUT_CHANGED_EVENTtriggers a re-read across tabs) and 255 added/changed lines inSettingsPage.test.tsxcovering the admin-default toggle and the eye-icon visibility column. i18n: new keys in theexternalLinks.*namespace (sidebarLayout / sidebarLayoutDescription / visibleInSidebar / hiddenFromSidebar / requiredInSidebar / setDefault / etc.), full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5168 leaves per locale. Vitest test timeout raised invitest.config.tsto absorb theuserEvent.setup({delay: null})cases in the heavierSettingsPageflows. Full vitest run green; ESLint clean;npm run buildclean; ruff clean. - Structured storage locations catalog (#1505 closing #1004, contributed by Poltavtcev) — Inventory gets a first-class catalog of physical storage spots (shelves, drawers, dryboxes) instead of free-text in the spool's
storage_locationfield. Spools now carry alocation_idFK alongside the denormalizedstorage_locationstring (kept for Spoolman wire format + label rendering). The Inventory page picks up a Locations button that opens an in-page modal — the original PR landed a standalone/inventory/locationspage; merged shape is a modal opened from Inventory so the catalog read sits next to the spool list. The modal handles create / edit / delete / pick-to-filter; row-click pushes the location_id into the Inventory filter state without a navigation. Deep-link?location_id=<n>(and?location_id=__none__for the unset bucket) still works for sharing or bookmarking. Backend: newLocationmodel +locationstable with case-insensitivename_key(LOWER(TRIM(name))) UNIQUE — concurrent creates on the same name resolve to a single 409 via theIntegrityError→ re-fetch shape in_create_location_or_get_existing. CRUD at/api/v1/inventory/locations, all five routes gated withRequirePermissionIfAuthEnabled(Permission.INVENTORY_READ|UPDATE). Delete is blocked whilespool_count > 0so the user can't strand spools. Single-write-path islocation_service::resolve_spool_location_fields()— both the internal-mode and Spoolman-mode spool routes feed through it solocation_idandstorage_locationcan never drift. Spoolman parity: location names sync into the local catalog onGET /spoolman/inventory/spoolsviamaybe_sync_spoolman_locations; rename cascades to every Spoolman spool viaclient.rename_location, with a per-spool PATCH fallback when the upstream's bulk endpoint isn't there (Spoolman <0.16 doesn't exposePATCH /location/{name}and returns 404/405).get_distinct_locationsnormalises both the olderlist[str]and the newerlist[dict]Spoolman payload shapes. Migration: inline indatabase.py::run_migrations— creates thelocationstable (DATETIME for SQLite / TIMESTAMP for Postgres), addsspool.location_idFK + index, then backfills the catalog from existing free-text values (GROUP BYLOWER(TRIM(storage_location))so case variants likeDrybox 1andDRYBOX 1collapse into one row). The legacyname_keybackfill runs BEFORE the dedup INSERT so a pre-existing locations row with NULLname_key(manually inserted before this feature shipped) gets its column populated first and the subsequent spool-link UPDATE can join on it. Post-migration warn-log flags any spools that still carry free-textstorage_locationwith nolocation_id— surfaces the rare mis-link case to ops instead of silently leaving them out of catalog filters. Rename safety: Spoolman PATCH runs BEFOREdb.commit(), cascade failure rolls back the local rename and raises HTTP 502 — without this ordering a partial failure left the catalog and Spoolman's per-spoollocationfield permanently diverged (the next sync recreates the old name as a duplicate catalog row). Legacy-row UPDATE matchesfunc.lower(func.trim(Spool.storage_location)) == old_name.strip().lower()so the SQL TRIM symmetry holds for whitespace-padded values. Cross-tab refresh:spoolman_inventory.pynow emitsinventory_changedon the 8 spool-mutating routes (create, bulk-create, update, delete, archive, restore, reset-bulk, weight, tag) — internal mode already broadcast in 12 places, Spoolman mode silently degraded before. TheuseWebSockethandler invalidatesinventoryLocationsQueryKeyon every such message so location counts stay in sync across tabs. Performance: the Spoolman→catalog sync used to fire on everyGET /spoolsrequest, hit Spoolman, and open a write transaction; now guarded by a 60s per-URL TTL cache (_spoolman_location_sync_last_run) so a polling UI doesn't burn a Spoolman round-trip + SQLite write per refetch. The route also passes its already-resolved client through to the sync so test fixtures that patch the route module's client also catch the sync's client lookup — without this the SSRF LAN-topology parametrize tests took ~45s on real TCP timeouts to RFC-1918 IPs (now 2.79s in isolation). Frontend:SpoolFormModallocation dropdown sendslocation_idonly (same shape in both inventory modes — nospoolmanMode ? ... : ...UI gate) and theonCreateLocationflow surfacesApiError.messageinstead of a generic toast so 409 / 400 / 500 stay distinguishable.LocationsModalpassesisLoadingtoConfirmModalduring delete so a mid-mutation cancel can't strand a toast on a dismissed dialog; Pencil / Trash icon buttons carryaria-labelfor SR announcement. i18n: newlocations.*namespace (20 keys: title, subtitle, add, edit, delete, empty, name, spools, manage, createPlaceholder, nameRequired, created, updated, deleted, saveFailed, deleteFailed, deleteBlocked, confirmDelete, confirmDeleteMessage, editAria/deleteAria), full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5168 leaves per locale. Tests: ~26 new acrossbackend/tests/unit/test_location_service.py(rename strip/lower symmetry, sync-from-Spoolman log-on-unavailable, list[dict] payload normalisation),backend/tests/unit/test_spoolman_inventory_methods.py(get_distinct_locationsshape guard × 4,rename_locationbulk-then-fallback × 4 — 200 / 404 / 405 / 5xx),backend/tests/unit/test_location_migration.py(NULL + whitespace-only storage_location skip, legacy NULL name_key ordering, case-variant dedup, idempotency),backend/tests/integration/test_locations_api.py(CRUD round-trip, rename cascade, IntegrityError → 409, PATCH/DELETE 404, auth-gate 401 on all five routes whenauth_enabled=true), andfrontend/src/__tests__/components/LocationsModal.test.tsx(12 cases: open=false renders nothing + no fetch, row click → onPickLocation + onClose, 2-level Escape dialog stacking, rename collision 409 toast, disabled delete onspool_count>0, etc.). FrontenduseWebSocket.test.tsexercises theinventory_changed→ invalidate['inventory-locations']round-trip. Full backend pytest 6025/6025 (67s with -n 30); frontend vitest 2141/2141; ruff clean;npm run buildclean; ESLint clean; i18n parity green. - Admin-configurable session lifetime (#1706, reported by AD3DStuff) — The 24-hour session cap that ships with Bambuddy was an intentional security hardening (audit finding M-2 reduced it from 7 days), but the "Remember Me" checkbox only controlled storage location (localStorage vs sessionStorage), not session duration. iPhone PWA users and homelab admins on trusted networks were getting kicked out every 24 hours with no way to extend it. New setting:
session_max_hoursunder Settings → Users with three presets (24h / 7 days / 30 days) plus a custom field, hard-capped at 30 days (720h). Default remains 24h so existing deployments and the M-2 audit baseline are untouched until an admin opts in. The Settings card surfaces a yellow warning whenever the value exceeds 24h: "Longer sessions reduce automatic logout protection. Recommended only for trusted single-user deployments." Backend wiring: newresolve_session_max_minutes(db)helper inbackend/app/core/auth.pyreads the setting, clamps to [1h, 720h], and falls back to 24h on missing / blank / unparseable values. The helper is called at all four token-issuance sites — plain/auth/login, 2FA TOTP/email completion, 2FA backup-code completion, and OIDC callback — so a long-session policy works uniformly regardless of how the user authenticates. DB errors in the resolver are deliberately NOT caught: login is already inside a transaction and a broken DB must abort the login rather than silently extend or shrink the session lifetime. Defense-in-depthSESSION_MAX_HOURS_HARD_CEILING = 720clamps any tampered DB row above the Pydantic ceiling. Already-issued tokens keep their original expiry — the new setting only affects future logins, so an admin lowering the value can't retroactively revoke active sessions and an admin raising it can't retroactively extend them. What this does NOT change: the "Remember Me" checkbox still controls only storage location (cleared on browser close vs persisted across restarts). The relabel from misleading-UX-perspective is left for a separate follow-up — that's a UX choice independent of the session-policy mechanism. API tokens (MAX_TOKEN_LIFETIME_DAYS), camera stream tokens (60min), WebSocket tokens (60min), and slicer download tokens (5min) keep their own TTLs and are unaffected. Tests: 15 new cases inbackend/tests/integration/test_session_policy.pysplit across three classes.TestResolveSessionMaxMinutespins the clamping resolver — missing row, empty string, unparseable value, zero/negative, 1h minimum, 7-day passthrough, 30-day passthrough, above-ceiling clamp.TestLoginRespectsSessionPolicydecodes the JWTexpclaim end-to-end and asserts the token returned by/auth/loginhonours the configured ceiling for the default-24h, configured-7d, and above-ceiling-clamp cases.TestSettingsAPIExposesSessionMaxHoursround-trips the field through/settings/(default = 24, valid update persists as int's string form, zero rejected with 422, above-ceiling rejected with 422). Existing 202-case auth + MFA suite still green. i18n: 8 new keys insettings.sessionPolicy.*namespace; full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity check 5149 leaves per locale. ESLint clean;npm run buildclean; ruff clean. - Per-VP "G-code injection" toggle for Studio Send / FTP uploads (#1516, contributed by phieb) — Queue-mode Virtual Printers gain a per-VP opt-in toggle that applies the Settings → G-code Snippets per-model start/end snippets to every job that lands via the VP — Bambu Studio's "Send", OrcaSlicer's "Print Plate", the VP's own FTP upload path. Before this change the snippets were only applied to items queued through the PrintModal's "Inject auto-print G-code" checkbox; VP-incoming jobs silently bypassed injection regardless of how the snippets were configured. Default off so upgraders don't silently start injecting: existing
gcode_snippetsinstalls keep their previous behaviour until the per-VP toggle is explicitly enabled. When on, the scheduler still no-ops unlessgcode_snippetsare configured for the target printer model, so the effective semantics are "inject when enabled AND snippets exist." DB column: newvirtual_printers.gcode_injection BOOLEAN DEFAULT FALSEwith a branchedis_sqlite()migration (SQLiteDEFAULT 0/ PostgresDEFAULT FALSE) matching thequeue_force_color_match/tailscale_disabledprecedent. Multi-plate stamping: the flag is set on every plate'sPrintQueueIteminside the per-plate loop introduced by #1697 / #1188, so a multi-plate "Send all" upload now gets snippets injected on each plate consistently — the original PR only stamped the first plate; the merge resolution wove the flag into the loop. Live-toggle correctness: the_sync_from_db_lockedchange detector now comparesinstance.gcode_injection != vp.gcode_injection, so toggling the value in the UI triggers a VP restart instead of letting the in-memory instance keep the stale flag and silently propagate it onto every subsequent upload — same shape as the #1552 family. Backed by a dedicatedtest_sync_from_db_restarts_on_gcode_injection_toggle. UI: new toggle onVirtualPrinterCard.tsx(queue mode only — the toggle is hidden in archive/review/proxy modes since the feature is queue-specific), with the standardupdateMutationsave-on-click + toast on success, plus thependingAction='gcodeInjection'opacity dim during the round-trip. PrintModal hardening: when "Inject auto-print G-code" is ticked on a reprint at quantity > 1, the modal now routes ALL copies through the queue (not just copies 2..N) so the scheduler injects every dispatch — see the separate reprint-quantity entry below for the full motivation. A newuseEffectclears the stalegcodeInjectionstate if the user ticks the box at quantity 2, then drops back to quantity 1 — the checkbox hides at that point and the state must follow, otherwise the immediate-reprint path would silently bypass injection. Diagnostics: the resolved start/end snippets (with{placeholder}substitution already applied) are logged at DEBUG so any "snippet didn't run" report can be traced from a log bundle. i18n: newvirtualPrinter.gcodeInjection.title+descriptionkeys translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); parity check 5188 leaves per locale, no English fallback. Tests: 2 new unit cases intest_virtual_printer.py(queue items opt in / out based on the VP flag), 2 new integration cases intest_virtual_printer_api.py(create defaults to false, PUT round-trips the value), 1 new sync-restart case, plus updates to_make_db_vpso the change-detector test fixture carries an explicitFalserather than relying onMagicMocktruthiness. 2 new PrintModal vitest cases pin the reprint dispatch matrix (injection ON queues all copies and dispatches none immediately; injection OFF keeps the immediate first copy and queues the rest). Full backend pytest 6167/6167; full frontend vitest 2154/2154; ruff clean;npm run buildclean.
Fixed
- 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'sproject_fileMQTT command for O1C2 carries two extra fields —nozzle_mapping(alist[int]of per-filament physical nozzle position IDs, populated from a priorget_auto_nozzle_mappinground-trip with the firmware) andnozzles_info(alist[dict]of per-extruder rack metadata:id/type/flowSize/diameter). The VP intake atvirtual_printer/manager.py:564-574only 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. Withoutnozzle_mappingon 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_queuereadsnozzle_mapping+nozzles_infoout 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, mirroringgcode_injection/filament_overridesper-plate stamping from #1697 / #1188). New nullable TEXT columnsnozzle_mapping/nozzles_infoonprint_queue— non-branched ALTER (same asams_mapping/filament_overridesprecedent atdatabase.py:944/955). The dispatcher reads the JSON strings off the queue item, parses them back to list/dict, and includes them on the publishedproject_filecommand as parsed JSON values (not strings — the wire shape matches BS's, same convention asams_mapping/ams_mapping2). Dual-nozzle gate atbambu_mqtt.py::start_print()keeps the fields off single-nozzle dispatches as defense-in-depth (is_dual_nozzleruntime flag already established bydevice.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:PrintQueueItemResponseparses both fields back tolist[int]/list[dict]so any future "edit print → nozzle" UI can read+round-trip them;PrintQueueItemUpdateaccepts them and the route handler serialises to JSON for storage (same shape asams_mapping). Tests: 3 new cases intest_virtual_printer.py::TestVirtualPrinterInstance(capture round-trip, NULL-on-omitted-fields, per-plate stamping on multi-plate) and 6 new cases intest_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 intest_printer_manager.py::test_start_print_calls_clientto add the two new kwargs to theassert_called_once_withmatcher. Full backendpytest -n 306176/6176 in 86.67s; ruff clean;npm run buildclean; 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 throughams_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/bambuddypath with "Permission denied" (#1774, reported by jmoore-skild) —install/docker-install.sh::create_install_dir(line 252) ranmkdir -p "$INSTALL_PATH"without sudo whileDEFAULT_INSTALL_PATH="/opt/bambuddy"(line 32) — root-owned on every Linux distro.set -eat line 20 then aborted the whole run before docker compose could ever pull the image. Anyone following the documentedcurl … | bashflow as a normal user hit this immediately. The native installer atinstall/install.sh:361already handles the same situation correctly withsudo mkdir -p+sudo chown; the Docker variant just never got the same treatment. Why the fix isn't a default-path change: the contributor's first instinct was to drop the default to~/bambuddysince the Docker installer only writesdocker-compose.yml+.envon the host (real app data lives in named volumes), butinstall/update.sh:4andinstall/update_macos.sh:4both defaultINSTALL_DIRto/opt/bambuddy, andinstall/README.md:274documentsINSTALL_DIR=/opt/bambuddy sudo ./update.shfor the update flow — changing the install default without coordinating the update path would silently break self-service updates for anyone following the docs verbatim. The actual gap is the missing privilege escalation increate_install_dir, not the default path. Fix:create_install_dirnow triesmkdir -p "$INSTALL_PATH" 2>/dev/nullfirst — the cheap no-sudo path covers--path ~/bambuddy,--path /srv/bambuddy, and any other writable target — and only falls back tosudo mkdir -p "$INSTALL_PATH"+sudo chown -R "$USER:$USER" "$INSTALL_PATH"when the unprivileged attempt fails. The chown is load-bearing: without it, the script would later try to writedocker-compose.ymland.envinto a root-owned dir as the unprivileged invoking user, kicking off a cascade of EACCES failures further down. Idempotent on re-run (the secondmkdir -psucceeds against the now-owned dir, no second sudo prompt).set -esurvives the redirected stderr because theif !construct is the documented escape from bash's exit-on-error semantics for an expected-failure check. Smoke-tested all three branches: writable target → no sudo prompt fires; idempotent re-run → no second sudo prompt; the failing-mkdir-then-fallback path →set -esurvives intact. What this does NOT change: the default install path stays/opt/bambuddyfor parity withinstall.sh/update.sh/ the documented update flow; the Windows mirror atinstall/docker-install.ps1already uses$env:USERPROFILE\bambuddy(per-user convention on Windows) and is untouched. No docs change required —install/README.mdand the wiki Docker page (bambuddy-wiki/docs/getting-started/docker.md) both still accurately describe the behaviour. - MakerWorld import/resolve/status fail under API-key auth even when the owner has a Bambu Cloud login (#1777, reported by Mx772) — The reporter (working on a browser extension that drives Bambuddy via
X-API-Key) noticed thatPOST /api/v1/makerworld/importandPOST /api/v1/makerworld/resolvereturned{"detail":"Downloading files from MakerWorld requires a Bambu Cloud login"}even when the key's owning user had a valid stored Bambu Cloud session, and the same imports succeeded from the web UI. Root cause is exactly the shape the reporter traced:require_permission_if_auth_enabledinbackend/app/core/auth.py:1414deliberately returnscurrent_user=Nonefor API-keyed callers — the comment at line 1408 makes this explicit and points atcloud.pyfor the resolver. The MakerWorld routes never got that resolver wired in, so_build_service(db, None)→get_stored_token(db, None)→ no token → the "requires Bambu Cloud login" branch fires regardless of what the owning account has set up. Same shape #1182 fixed for cloud slicer presets, and the canonical fix for non-/cloud/*routes is already in the codebase asresolve_api_key_cloud_owner(cloud.py:128-160) — used byslicer_presets.py:491andlibrary.py:3871. The MakerWorld routes were missing the wire-up. Fix: Three routes get the extraapi_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner)parameter —get_status,resolve_url,import_instance— and each resolvescloud_token_user = current_user or api_key_cloud_ownerbefore callingget_stored_token/_build_service.import_instanceadditionally usescloud_token_user.idfor theowner_idargument tosave_3mf_bytes_to_library(which translates toLibraryFile.created_by_id), so library rows imported via API key are now attributed to the key's owner instead of staying NULL./recent-importsis unchanged — it only usescurrent_useras a permission gate (_ = current_user) and never touches the cloud token. The fix preserves fail-closed semantics for keys without thecan_access_cloudflag:resolve_api_key_cloud_owneralready fences onapi_key.user_id is not None and api_key.can_access_cloud(cloud.py:158), so a key with only the per-route scope (can_read_status/can_manage_library) still surfaces the "requires Bambu Cloud login" error path — no new auth gap. Two scope fields the API key needs: the per-route scope (MAKERWORLD_VIEW→can_read_status,MAKERWORLD_IMPORT→can_manage_libraryper_APIKEY_SCOPE_BY_PERMISSIONincore/auth.py) AND the orthogonalcan_access_cloudflag (separate column on theapi_keystable). The fix doesn't change that surface — it just stops dropping validcan_access_cloud=Truekeys on the floor. Tests: 6 new cases inbackend/tests/integration/test_makerworld_apikey_auth.pypinning the full surface — API key withcan_access_cloud=True+ owner-has-token →/statusreportshas_cloud_token=True,/resolvebuilds the service with the owner User (asserted on the_build_servicemock's call args),/importsucceeds end-to-end and the resultingLibraryFile.created_by_idmatches the API-key owner; API key withcan_access_cloud=False→ status still reportshas_cloud_token=False(no widening) and import-row'screated_by_idstays NULL; JWT-authenticated parity check confirms the existing user-session flow is unchanged by the addedDepends. 6/6 new tests green; full backend suite (6157 tests) still green; ruff clean. No frontend change, no DB migration, no new permission, no new dependency. The reporter's browser extension and any other API-keyed Home Assistant / automation integration unblocks immediately on next deploy. - Archive thumbnails missing for prints sliced via the docker sidecar (#1759, reported by VID-PRO) — The reporter (P2S) noticed every print sliced through Bambuddy's BS docker sidecar landed in the archive with no thumbnail, while the same model sliced from desktop Bambu Studio on their laptop showed the cover image. The "Some recent prints couldn't be archived with thumbnails" banner pointed at install step 4 (
Store sent files on external storage) which is unrelated — that flag is set on FTP-fetch failures, not on missing-thumb in the sliced 3MF. Root cause is upstream of Bambuddy entirely: neither the BambuStudio CLI nor the OrcaSlicer CLI rendersMetadata/plate_N.pngwhen invoked headlessly with--slice --export-3mf. That render is a separate code path triggered by the--export-pngflag, which is mutually exclusive with--export-3mfand additionally requires a working display backend (BS 02.07.x's bundled GLFW is hard-locked to Wayland — evenXDG_SESSION_TYPE=x11+GDK_BACKEND=x11+QT_QPA_PLATFORM=xcbdon't switch it back to X11, so an Xvfb display in the sidecar wouldn't help even if we wired a second-pass call). Confirmed empirically by feeding a thumbnail-strippedCube-MegaS.3mfthrough both sidecars: both produced.gcode.3mfwith zero PNG entries. The Orca sidecar has been silently shipping thumbnail-less 3MFs from STL inputs since it launched; nobody noticed until VID-PRO filed this against BS specifically. Fix: Newbackend/app/services/plate_thumbnail.pyrenders the missing thumbnails server-side after the slice returns.inject_plate_thumbnails_if_missing(threemf_bytes)parses the sliced zip, finds everyMetadata/plate_N.gcodeentry that doesn't have a matchingplate_N.png, loads3D/3dmodel.modelvia trimesh, renders an isometric Bambu-green-on-dark view at 512×512 (plate_N.png) + 128×128 (plate_N_small.png) using the same matplotlib Agg pipeline asstl_thumbnail.py, and re-packs the zip with the PNGs injected. Visual style deliberately matches Bambuddy's existing library thumbnails — archive cards stay consistent inside Bambuddy rather than chasing parity with desktop Studio's plate render. Best-effort: input bytes are returned unchanged on any failure (no model file, trimesh can't parse, matplotlib render fails) so the slice flow itself can't fail because of a missing thumbnail. Idempotent: re-running on a previously-injected 3MF hits the no-op fast path and returns the input verbatim. Wired into bothbackend/app/api/routes/library.pyslice paths (library-file slice at line 3593 + archive re-slice at line 3718) viaresult = result._replace(content=inject_plate_thumbnails_if_missing(result.content))immediately beforeout_path.write_bytes(...)— covers the cross-class merged-multi-plate path (slicer_3mf_convert.merge_plate_3mfs) automatically since merged bytes flow into the same write site. Dependencies: trimesh's 3MF loader importsnetworkx(scene-graph traversal) andlxml(model.xml parse) lazily inside the 3MF code path — both added torequirements.txtbecause they aren't strict trimesh transitives but the loader fails at runtime without them (ModuleNotFoundError). Tests: 7 new cases inbackend/tests/unit/services/test_plate_thumbnail.py: input bytes returned unchanged (identity) when every plate already has a thumbnail (desktop-Studio fast path); both PNG sizes injected when missing; injected PNGs decode as 512x512 + 128x128 RGBA; multi-plate 3MF with one pre-existing thumbnail only renders the missing slots (pre-existing bytes preserved verbatim); 3MF with no3D/3dmodel.modelreturns input unchanged; non-zip input returns input unchanged; idempotent on second pass. Verified end-to-end: runninginject_plate_thumbnails_if_missingagainst the actual BS sidecar and Orca sidecar outputs (/tmp/bs-no-thumb-out.3mf//tmp/orca-no-thumb-out.3mf— both 25932/25992 bytes with zero PNG entries) produces 3MFs with validMetadata/plate_1.png+Metadata/plate_1_small.pngcontaining the rendered cube model (38.5% Bambu-green pixel coverage confirms the model is actually drawn, not a blank canvas). 6151/6151 backend tests still green; ruff clean. No sidecar Dockerfile change required — earlier experiments with Xvfb +xvfb-runinDockerfile.bambu-studiowere a false start (the BS GLFW Wayland lock means no X display can help) and have been reverted from the sidecar repo. No frontend change required — the archive UI already extractsplate_1.pngfrom the sliced 3MF, the cards just had nothing to show. - Local Presets page: deleted row stayed visible until refetch returned, allowing a second delete click → 404 — On the Slicer → Local Profiles page, clicking Delete → Confirm fired the
DELETE /api/v1/local-presets/{id}request, then theonSuccesshandler closed the confirmation modal and calledqueryClient.invalidateQueries({ queryKey: ['localPresets'] })without awaiting it. The global QueryClient defaultstaleTime: 1000 * 60(App.tsx:78) doesn't blockinvalidateQueriesfrom refetching, but the refetch is async — so for ~hundreds of ms the rendered table still showed the just-deleted row, and a quick re-click on the same row opened a fresh confirm dialog → second confirm → backend returns 404 (row already gone) → confusing error toast. Caught while reproducing #1713: log showedDELETE /api/v1/local-presets/42 → 200followed by two→ 404for the same id within 4 seconds. Fix: Add an optimisticqueryClient.setQueryData<LocalPreset[]>(['localPresets'], …)infrontend/src/components/LocalProfilesView.tsx::deleteMutation.onSuccessthat filters the deleted row out of the cached list synchronously, then leaves the existinginvalidateQueriescalls in place to reconcile any drift. Row disappears the instant the DELETE returns 200, no re-click window. The same import path'simportMutationdoesn't need the same treatment because additions can't trigger the symmetric "row I just acted on is still there" → 404 loop. ESLint clean;npm run buildclean; existingLocalProfilesView.test.tsxsuite still green (no new test added — the bug is a render-timing window the existing render-based vitests don't observe; the existing onSuccess assertions still pass with the new optimistic write). - SpoolBuddy inventory search now matches spool ID, slicer filament name, and storage location (#1738, reported by shaddowlink) — The reporter found that typing a numeric spool ID into SpoolBuddy → Inventory's search box returned no results, even though the same query in Bambuddy's main Inventory page worked. Root cause:
frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx:147-155reimplemented the search filter inline and only matchedmaterial,subtype,brand,color_name, andnote. The main Inventory page delegates to the sharedfilterSpoolsByQueryhelper infrontend/src/utils/inventorySearch.ts:7, which additionally matchesString(spool.id),slicer_filament_name, andstorage_location. SpoolBuddy had diverged. Fix: replace the inline filter with a single call tofilterSpoolsByQuery(list, searchQuery.trim()). Both inventory modes (internal viagetSpools, Spoolman viagetSpoolmanInventorySpools) return the sameInventorySpoolshape, so this covers both paths in one drop. SpoolBuddy now matches Bambuddy's search behaviour across all eight fields. Tests: newSpoolBuddyInventorySearch.test.tswith 4 cases pinning the parity — exact spool ID match, partial spool ID match, the five pre-fix fields still match, and the three newly-included fields (storage_location, slicer_filament_name, plus implicit id) match. ExistinginventorySearch.test.tsID matching test (#1336) still green. ESLint clean;npm run buildclean. No backend change, no i18n, no new permission. - Sidebar entries for Files / Archives / Queue no longer hide from non-admin users with granular read access (#1755, reported by knifesk) — The reporter noticed the File Manager sidebar entry was hidden for a default Operators user even though the same user could load
/filesdirectly and the backend API accepted their requests. Root cause is broader than reported:frontend/src/components/Layout.tsx::navPermissionsmappedfiles → 'library:read',archives → 'archives:read',queue → 'queue:read'— the LEGACY permission flags — but the default Operators group atbackend/app/core/permissions.py:368-380is seeded with the GRANULAR variants only (ARCHIVES_READ_OWN.value,QUEUE_READ_OWN.value,LIBRARY_READ_OWN.value). The migration path atbackend/app/core/database.py:3034-3041also flips legacy*:read→*:read_ownon existing non-admin groups. So a non-admin user never holds the legacy permission,hasPermission('library:read')returns false, sidebar entry is suppressed — for all three resources, not just Files. Admins getALL_PERMISSIONSwhich includes the legacy variant, so the sidebar always renders for them, which is why this regression went unnoticed until a real non-admin Operator account landed in #1755. Fix:navPermissionsnow acceptsPermission | Permission[]and the three affected resources list all three tiers (*:read,*:read_own,*:read_all). TheisHiddencheck switches on the array type —some(hasPermission)for arrays, current behavior for single values. Nothing else in the gate logic changed.frontend/src/api/client.tsPermission type extended with the missing granular variants (archives:read_own,archives:read_all,queue:read_own,queue:read_all,library:read_own,library:read_all) — these existed in the backend enum and were already being shipped to the frontend in/auth/me, but the TS type didn't declare them so any new code wanting to gate on the granular tier would TypeScript-error. What this also fixes downstream: any future feature that needs to gate UI on*:read_own/*:read_allcan now do so without re-adding the same type entries. Tests: 5 new cases inLayout.test.tsx::'Sidebar gate accepts granular read tiers (#1755)'— Files visible with onlylibrary:read_own, Files visible with onlylibrary:read_all, Archives visible with onlyarchives:read_own, Queue visible with onlyqueue:read_own, and the negative case (printers:readonly — none of Files / Archives / Queue render). 22/22 Layout vitests green; ESLint clean;npm run buildclean. No backend change, no DB migration, no new i18n keys. No new permission — just unmasks UI for users who already had backend access. - Push notification for "Printer offline" now actually fires (#1752, reported by saint-hh) — The notification provider's
on_printer_offlinetoggle has shipped since the notifications feature landed: schema field, DB column,notification_template.pyentry, and the dispatcherNotificationService.on_printer_offline(printer_id, printer_name, db)are all in place. What was missing was the caller — nothing in the codebase actually invoked the dispatcher when a printer went offline. The reporter (P2S, smart-plug-cuts-power scenario) confirmed turning the toggle on did nothing; only the print-failure notification fired when power was restored, via the firmware'sgcode_state=FAILEDreport on MQTT reconnect. Why the toggle was orphan: every other provider event (on_print_start,on_print_complete,on_print_progress,on_printer_error, etc.) has a clear call site undermain.py::on_printer_status_changeor alongside the print-lifecycle hooks. The offline event was the only edge-triggered toggle without one — the dispatcher and template predated the wiring step and were silently shipped. Both upstream offline-trigger paths (smart_plug_manager→printer_manager.mark_printer_offline()andbambu_mqtt.py::check_stalenessafter the 30s STALE_RECONNECT_COOLDOWN) route through_on_status_changealready and reachon_printer_status_change; the handler just didn't act on the disconnect edge. Fix: edge detection inon_printer_status_changewatchesstate.connectedagainst the previous observation per printer (_printer_last_connected: dict[int, bool]). On the True → False transition it schedules_maybe_notify_printer_offline(printer_id)as a background asyncio task; on the next True observation it cancels any pending task. The helper sleeps_PRINTER_OFFLINE_NOTIFY_DEBOUNCE_SECONDS = 60.0then re-checksprinter_manager.is_connected(printer_id)— only fires the notification if the printer is still offline. Why 60s debounce: sized againstbambu_mqtt.py::STALE_RECONNECT_COOLDOWN = 30s— a single stale-trigger + reconnect cycle isn't enough to fire, only a real outage that survives one full cooldown notifies. Transient MQTT blips (WiFi roam, broker reload, brief packet loss) recover within the window and the cancellation path kicks in. Edge-case handling: initial observation with no prior connected state doesn't fire (covers Bambuddy startup with an already-offline printer); a False → False repeat doesn't reschedule (the in-flight task stays in place rather than resetting the clock on every status callback, which would otherwise mean the notification never fires); the task entry pops from_printer_offline_notify_tasksin the finally block whether the notification fired, the printer reconnected, or the task was cancelled mid-await. No symmetricon_printer_onlineevent: the reporter explicitly noted the "printer lost power and interrupted the print" notification already fires when power is restored — that's the print-failure notification, triggered by the firmware reportinggcode_state=FAILEDfor the interrupted print on MQTT reconnect. That covers the "printer is back" channel without a new toggle. If the user then resumes the print, no print_start notification fires (Bambuddy'sbambu_mqtt.py:3039explicitly suppressesis_new_printfor PAUSE → RUNNING to prevent duplicates when resuming from pause), but that's a separate scope from offline-detection. Tests: 9 new cases intest_printer_offline_notification.pysplit across two classes.TestMaybeNotifyPrinterOfflinepins the debounced helper: fires notification when still offline at end of window, doesn't fire when printer reconnected during debounce, doesn't fire when the printer disappeared from the DB (uninstall mid-window), clears_printer_offline_notify_tasks[printer_id]after run.TestOfflineEdgeDetectionpins the edge logic insideon_printer_status_change: first observation (connected) doesn't schedule, first observation (disconnected) doesn't schedule (the no-prior-True case — important for startup), True → False schedules a task, reconnect cancels the pending task, repeated False observations don't replace the in-flight task. Full backend suite still green; ruff clean.