Note
This is a daily beta build (2026-06-09). It contains the latest fixes and improvements but may have undiscovered issues.
Docker users: Update by pulling the new image:
docker pull ghcr.io/maziggy/bambuddy:daily
or
docker pull maziggy/bambuddy:daily
**Tip:** Use [Watchtower](https://containrrr.dev/watchtower/) to automatically update when new daily builds are pushed.
Added
- Archives page banner: reactive install-step-4 nudge for the slicer-side setting — Companion to the new
external_storagediagnostic check. The diagnostic catches the printer-side variant of "Store sent files on external storage" viahome_flagbit 11. The slicer-side variant on older BambuStudio / OrcaSlicer never reaches the printer, so the diagnostic passes even when the option is off in the slicer. The deterministic symptom is the archiver creating a row withextra_data.no_3mf_available=True(main.py:2770) — that's the signal this banner watches. New backend endpointGET /archives/no-3mf-warningreturns{has_fallback: bool}— true iff any archive in the last 30 days has the flag set AND isn't soft-deleted. The 30-day window prevents old never-fixed installs from showing the banner forever; the soft-delete filter respects the user clearing the evidence. Frontend banner sits at the top of the Archives page (amber, dismissible) — "Some recent prints couldn't be archived with thumbnails…" + link to install step 4 in the wiki. Dismissal is one-shot vialocalStoragekeyarchiveNo3MFWarningDismissed(matches the existingLayout.tsxupdate-banner pattern but persistent across sessions, since "you've been told" should outlive a browser restart). React-Query isenabled: !dismissedso the endpoint isn't polled after dismissal. 5 backend integration tests (TestNo3MFWarning) cover: recent fallback returns true, no archives returns false, archives without the flag returns false, >30-day-old fallbacks ignored, soft-deleted fallbacks ignored. i18n: 4 new keys (title,body,docsLink,dismissLabel) underarchives.no3mfBannertranslated to all 11 locales — no English fallbacks. - Connection diagnostic now verifies install step 4 ("Store sent files on external storage") — Many users miss this setting when adding their first printer; without it BambuStudio / OrcaSlicer never leave a
.gcode.3mfon the printer's SD card, every archived print falls back to no-thumbnail / no-metadata, and the cause is invisible until the user notices the archive is empty. The trap with detecting this: on newer firmware (P2S 01.02 / Bambu Studio 2.6+) the toggle moved onto the printer itself and is pushed on MQTThome_flagbit 11 (Bambuddy already parses this intostate.store_to_sdcard). On older versions it's a purely slicer-side preference invisible to the printer. An FTP upload-probe approach was tried first — it always passed regardless of the slicer toggle because the/cachedirectory is always writable from Bambuddy's perspective; the slicer toggle only controls what BambuStudio chooses to do, not what the printer accepts from other clients. Confirmed empirically against an X1C + H2D with the slicer option toggled off (probe still succeeded,home_flagbit 11 stayed True). Fix: newexternal_storagecheck readsstate.store_to_sdcarddirectly. Pass when the printer reports the bit on, fail when off, skip when no live MQTT state or the field has never been populated (older firmware that doesn't pushhome_flag). Localised fix-text points at install step 4 with both the printer-side and slicer-side variants spelled out; theskiptext explicitly calls out the older-slicer limitation so users on that path know to verify manually. Slot in the check list sits betweenport_ftpsandmqtt_auth. 5 new tests (TestExternalStorageCheck) cover pass-on-true, fail-on-false, skip-on-disconnect, skip-on-pre-add (no state), skip-on-missing-field. The reactive symptom-side detection — a one-time banner the first time the archiver recordsextra_data.no_3mf_available=Trueafter a slicer-initiated print — is planned as a separate follow-up to cover the slicer-only setting case. Wiki updated on the System page (features/system-info.md) and the Troubleshooting page (reference/troubleshooting.md). i18n: 4 new keys (title, pass, fail, skip) localised to all 11 locales (de, en, es, fr, it, ja, ko, pt-BR, tr, zh-CN, zh-TW) — no English fallbacks. - "Open in Slicer" desktop target is now configurable separately from the API sidecar slicer (#1329, reported by hasmar04) — Reporter wanted to slice via the Bambu Studio sidecar but open files locally in OrcaSlicer; the existing
preferred_slicersetting drove both, so picking one forced the other. The slicer-URI flow on Workflow → Slicer literally swapped the BambuStudio handler for the OrcaSlicer one whenever the user switched the API choice. Fix: newopen_in_slicersetting ('bambu_studio' | 'orcaslicer' | null) drives only the desktop "Open in Slicer" URI handoff; the in-app SliceModal + sidecar URL routing inlibrary.py,archives.py,slicer_presets.pycontinue to usepreferred_slicerexactly as before. Default isnull— the frontend falls back topreferred_slicerso existing installs behave identically until a user changes it (no migration, no churn). Storage lives in the existingapp_settingskey/value table; the PUT path serialises a Python None as the literal string"None", and the GET path normalises it back via a new branch in_build_settings_responsematching the existingdefault_printer_idconvention — without that normalization the frontend can't tell "explicit override absent" from "explicit override set to a bogus value". Frontend: Settings → Slicer card relabels the existing dropdown's description ("Slicer used for in-app slicing via the API sidecar"), adds a new "Open in Slicer" dropdown below it with three options — "Same as API slicer" (the inherit-from-preferred default), "Bambu Studio", "OrcaSlicer".ArchivesPage(5openInSlicerWithTokencall sites),MakerworldPage(the URI handoff branch whenuseSlicerApi=false), andModelViewerModal(4openInSlicer(...)call sites) all switched from readingsettings?.preferred_slicertosettings?.open_in_slicer ?? settings?.preferred_slicer. MakerworldPage's "Slice in {{slicer}}" button label additionally branches onuseSlicerApi: when on, the label reflects the API slicer; when off, the desktop slicer — so the button text always matches what the button actually does. The OrcaSlicer "known CLI bugs" warning stays attached to the API dropdown (where it belongs — it's about the sidecar's CLI). i18n: 3 new keys in all 11 locales (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW) —settings.openInSlicerLabel,settings.openInSlicerInherit,settings.openInSlicerDescription— plus an updatedsettings.preferredSlicerDescriptioneverywhere (the old wording "Choose which slicer application to open files with" became wrong once the field stopped driving the desktop handoff). No English fallbacks per the project's hard rule. Tests: 3 new inTestOpenInSlicerOverridepin the contract — default is null, override persists across GET, explicit reset to null round-trips correctly without leaving the"None"string leak. Full backend suite green (5798/5798); frontend ESLint + build clean; vitest on SettingsPage + MakerworldPage 48/48 green; i18n parity 5095 leaves × 11 locales green. - Queue items + Print modal now show the build plate type, per-plate accurate (#1281, reported by CMW-ISS) — Reporter on a multi-printer farm with 40+-plate runs needed to walk to the printer with the right physical plate; the archive card had recently grown a bed-type badge, but the queue and the scheduling modal didn't. They were having to open the source 3MF in the slicer to look up which plate each queued / scheduled job needs. Backend: new
extract_bed_type_from_3mf(file_path, plate_id)helper inutils/threemf_tools.py, alongside the existingextract_filament_usage_from_3mfshape — readsMetadata/slice_info.config, finds the<plate>with the matchingindex, returns itscurr_bed_type. Whenplate_idis None it returns the first plate's value (matches the archive-level capture convention).PrintQueueItemResponsegains abed_type: str | Nonefield;_enrich_responsepopulates it fromarchive.bed_type/library_file.file_metadata["bed_type"]as the file-level default, then overrides per-plate via the new helper whenitem.plate_idis set. This matters becausearchive.bed_typeis captured at ingest as the FIRST plate's value only (seeservices/archive.py:235) — a 40-plate 3MF mixing PEI + Engineering returns "PEI" for every plate at the archive level, even though the user's plate 17 actually needs Engineering. The per-plate override re-reads the 3MF and returns the truth./archives/{id}/plates(and the library-file equivalent) now includebed_typein each plate object so the PrintModal's plate selector can render the badge inline. Frontend: queue card meta row gains a bed badge after filament weight — uses the existinggetBedTypeInfo(bed_type)helper fromutils/bedType.ts(the same one the archive card uses, so all 11 canonical bed labels + icons are covered including the BambuStudio / OrcaSlicer spelling drift). PrintModal's per-platePlateSelectorshows the bed badge under each plate's filament line; the modal header carries a bed badge for the selected (or sole) plate, surfaced before the user hits Schedule.PlateInfo+PlateMetadatatypes both get an optionalbed_typefield. No new i18n keys needed —getBedTypeInforeturns the canonical English plate name as the human label, matching the archive card's existing convention. Tests: 8 new unit cases intest_threemf_tools.py::TestExtractBedTypeFrom3mfpin the helper (single-plate, multi-plate per-plate, no-plate-id defaults to first, unknown-plate-id → None, plate-without-bed-type → None (no fall-through to another plate's value), missing slice_info, invalid file, whitespace trim). Full backend suite green (3848/3848); frontend build clean; ESLint clean; vitest on touched pages 81/81; i18n parity 5092 leaves × 11 locales green.
Added
- Print Log page: per-row failure-cause classification (#1687 part 4, reported by IndividualGhost1905) — Reporter clarified after part 1 shipped that what he actually wanted for point 2 was failure-cause grouping on the log (spaghetti, jam, bed-adhesion, etc.), not the archive tags I'd pointed him at. Archive
tagsdescribe the model (home decor, toys); the log row needs to describe what went wrong on a single print event. Different surface, different lifetime. What was already there:PrintLogEntry.failure_reason: String(100)already exists, gets mirrored fromarchive.failure_reasonwhen the user edits the archive (seearchives.py:1421for the mirror that ships with #1444), and the Failure Analysis widget already groups by it. So the storage and the aggregation were both done — the only gaps were (a) the Print Log table couldn't render the value because the GET serialiser silently dropped it fromPrintLogEntrySchema, and (b) orphan log entries (failures with no archive — dispatch errors, aborts before archive creation, manual entries) had no edit path at all because the Archive Edit modal can't reach them. Fix: four pieces. (1)print_log.pyGET endpoint now includesfailure_reason(andarchive_id,created_by_id) in the serialised response — pre-fix it was silently None in every response even when the column was populated. Regression guard added. (2) NewPATCH /print-log/{entry_id}endpoint accepting{failure_reason, status}, gated onrequire_ownership_permission(ARCHIVES_UPDATE_ALL, ARCHIVES_UPDATE_OWN)— same ownership shape as the per-row delete that already shipped. Backend validatesfailure_reasonagainst the same canonical vocabulary the Archive Edit modal uses (11 enumerated keys + empty-string-clears + theothercatch-all); unknown values return 400 rather than getting stored as raw garbage (the i18n layer renders the value as a key, so an unrecognised one would surface as a literal string in the UI). Status validated against the 5-value{completed, failed, stopped, cancelled, skipped}set. Empty-stringfailure_reasonstores back as NULL so the column'snullable=Trueintent is preserved end-to-end. (3)FAILURE_REASON_KEYSconstant moved to an export fromEditArchiveModal.tsxso the new editor reuses the exact same vocabulary as the archive editor — backend and frontend stay in lockstep. (4) Frontend: pencil icon added beside the existing trash icon on every Print Log row, gated onarchives:update_own/archives:update_all. Click opens a compact two-field modal (status + failure reason dropdowns). Save invalidates bothprint-logandarchives-statsquery keys so the Failure Analysis widget reflects the re-classification on the same response cycle. Failure reason is also rendered as a sub-label under the status badge in the table, mirroring the per-archivePrintLogTable.tsxconvention so the two views agree. i18n: 10 new keys (editEntryTitle,editEntryDescription,entryUpdated,entryUpdateFailed,archives.permission.noEdit, plus a 5-keystatusesblock) translated across all 11 locales — no English fallbacks perfeedback_translate_dont_fallback. Tests: 8 new backend integration cases — GET surfacesfailure_reason(regression guard for the silent-drop bug), PATCH sets / clears / rejects unknown failure_reason, PATCH updates status, PATCH rejects unknown status, PATCH returns 404 on missing ID, PATCH works on orphan entries (archive_id IS NULL) — the actual reason this endpoint exists. Full backend suite 5843/5843 green; ruff clean. Frontend vitest 2108/2108 green; ESLint + build clean. i18n parity check 5110 leaves × 11 locales green. - Print Log page: per-row delete (#1687 part 1, reported by IndividualGhost1905) — Reporter noted that the existing "Also remove this print from Quick Stats" toggle on archive delete is one-shot: if you tick "keep stats" at delete time, there was no later way to drop the row from /stats; and rows that aren't tied to an archive (errors, aborts, manual entries) had no delete affordance at all. Fix: every row in the Archives → Print Log table now has a trash icon next to the filament cell, gated on
archives:delete_own(own rows) orarchives:delete_all(any row), matching the archive-delete permission shape. Click → confirm modal → row is gone, and because /archives/stats aggregates overPrintLogEntrythe filament / time / cost contribution drops out of Quick Stats in the same response cycle. The matching archive (if any) is untouched — the log row is a sibling, not a child. Backend: newDELETE /print-log/{entry_id}mirrorsdelete_archive's ownership flow viarequire_ownership_permission(ARCHIVES_DELETE_ALL, ARCHIVES_DELETE_OWN); owners can drop their own rows, admins can drop any row, missing IDs return 404 rather than 200-silently. Frontend: newdeletePrintLogEntryAPI helper, per-row mutation that invalidates bothprint-logandarchives-statsquery keys so the totals re-render without a manual refresh. i18n: 4 new keys (deleteEntryTitle,deleteEntryConfirm,entryDeleted,entryDeleteFailed) translated across all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Tests: 3 backend integration cases — delete drops the row from /stats while keeping the linked archive listed, missing ID returns 404, delete-one does not touch siblings (regression guard against an accidentaldelete(PrintLogEntry)without awhere). Frontend ArchivesPage / PrintLogModal vitests stay green (31 / 31). i18n parity green (5099 leaves × 11 locales). Issue #1687 also asks for per-row tagging (already covered byEditArchiveModal's tags field) and per-row filament-usage-history edits (deferred — see the issue thread for the reasoning).
Fixed
- PostgreSQL restore from a SQLite backup no longer deadlocks against the print scheduler (reproduced 2026-06-09 restoring a native install's backup into a fresh Docker+Postgres deploy) — Reporter (Maziggy) backed up the native install, brought up the new Docker image against an external Postgres, hit Restore in Settings → Backup. ~2 seconds in, the restore aborted with
asyncpg.exceptions.DeadlockDetectedError: Process X waits for AccessExclusiveLock on relation 109940; Process Y waits for RowExclusiveLock on relation 110182. Root cause: the existingclose_all_connections()step before the DB swap only disposes the SQLAlchemy engine's connection POOL — the asyncio tasks that USE the engine keep running. Theprint_scheduler.run()loop (30 s cadence) andsmart_plug_manager._snapshot_loop()(30 s cadence) wake up after the dispose, callasync_session(), lazily reopen a pool connection, and start a normal transaction that grabsRowExclusiveLockonprint_queue/smart_plug_energy_snapshots. The restore'sDROP TABLE IF EXISTS public.<tbl> CASCADEpass in_import_sqlite_to_postgresneedsAccessExclusiveLockon every public table — AB/BA lock-order conflict, classic Postgres deadlock, restore transaction rolled back. The log confirms:13:44:53,669restore begins →13:44:53,680print_scheduler fires queue check →13:44:55,607smart_plug_manager fires snapshot →13:44:55,607deadlock detected. The existing code already pausedvirtual_printer_managerbefore restore for file-lock reasons; the other timer-based DB writers were missed. Fix — two layers. (1) Beforeclose_all_connections(), pause the four most active timer-based DB writers via their existing stop affordances:print_scheduler.stop(),smart_plug_manager.stop_scheduler(),notification_service.stop_digest_scheduler(),await background_dispatch.stop(). Thenawait asyncio.sleep(1.0)to let in-flight loop iterations commit and release their sessions before the engine pool gets disposed. We don't restart the services on success because the restore handler already tells the user to restart Bambuddy to pick up the new DB. (2) Belt-and-braces inside_import_sqlite_to_postgres: prependSET LOCAL lock_timeout = '10s'to the begin-block before theDROP TABLE CASCADEpass, so any residual writer that slips through the pause window (per-printer MQTT clients writing reactively to state changes, the hourly AMS history recorder firing inside the restore window, etc.) surfaces a fastlock_timeouterror instead of producing a fresh deadlock or hanging the restore for 30+ seconds.SET LOCALis transaction-scoped so the global default applies to every other DB caller. Scope clarification: there are ~12 background services started at lifespan startup; the four paused here are the ones with the tightest cadences. Slower-cadence services (github_backup_service,local_backup_service,library_trash_service,archive_purge_service, AMS history, runtime tracking, SpoolBuddy watchdog, camera cleanup) all fire on hour-or-longer intervals and are statistically very unlikely to land inside a few-second restore window; the lock_timeout layer catches them if they do. Tests:test_restore_sqlite_wal_safety.pyandtest_settings_api.pyintegration suites (53 tests) stay green on the edited handler; ruff clean; runtime smoke (from backend.app.services.X import Y+hasattr+iscoroutinefunctioncheck) confirms all four stop signatures match the patch's sync/async mix. - Configure Slot now keeps the active K-profile on reopen for assigned-but-unconfigured slots (#1689 follow-up, reported and patched by Spionkiller01) — After the original #1689 fix shipped, Spionkiller01 found a residual case: on a slot that's physically loaded but unconfigured (filament inserted, but the printer hasn't bound a preset yet —
tray_type="",tray_info_idx="", noslot_preset_mappingsrow), the first open of Configure Slot showed the right K-profile, but closing it with the X and reopening it dropped back to "default 0.020". Clicking "Configure slot" (Apply) once persisted it, but the user shouldn't have to. Root cause: the original #1689 cali_idx safety net was unreachable on this code path.matchingKProfilesinConfigureAmsSlotModal.tsx:751early-returned[]whenselectedPresetInfowas null — andselectedPresetInforesolves to null exactly when there's no resolvable slot preset (unconfigured slot, no mapping row). The "always include the slot's currently-active K-profile by cali_idx" branch lives past the main name+id matcher, so it never ran from the no-preset path. On first open a freshly-cached preset briefly let the safety net trigger; on reopen the live slot state had no preset, returned[], the auto-select effect saw no candidates, the modal fell back to default 0.020. Fix (verbatim from Spionkiller01's H2C-tested diff, with the existing extruder guard): split the early return into two — still short-circuit on missing kprofilesData, but whenselectedPresetInfois null andslotInfo.caliIdx > 0, find the active profile byslot_id === activeIdx(extruder-matched when known) and return it as a single-item list. The auto-select effect downstream then pre-selects it on reopen with no extra change. Strictly additive: with a resolvable preset present the existing matcher runs untouched; withcaliIdx === 0 || nullthe function still returns[](no unrelated profiles leak in). Tests: new vitest casesurfaces the slot's active K-profile when no preset is resolvable (#1689 follow-up)exercises the path withtrayType='', nosavedPresetId, andcaliIdx=6against a K-profile fixture atslot_id=6— asserts the dropdown surfaces it. Verified the test fails without the patch (stash → run filter → fail; pop → run → pass). The existingcaliIdx === 0guard test continues to pass under the new branch. Full ConfigureAmsSlotModal vitest 24/24 green. Credit: Spionkiller01 for spotting the residual edge case after merge, producing the diff, and testing live on an H2C —Co-Authored-Byon the commit. - K-profile matching now prefers filament_id over parsed names — surfaces custom profiles in the spool form AND fixes Configure Slot showing "default 0.020" for an actively-bound K-profile (#1688 + #1689, both reported and diagnosed by Spionkiller01 with concrete H2C testing; #1689 also reported by IndividualGhost1905) — Two related symptoms on different UI surfaces, same root cause. #1688: spool form's PA-profile suggester (
frontend/src/components/spool-form/PAProfileSection.tsxviaisMatchingCalibrationinspool-form/utils.ts) only matched K-profiles by parsing the profile name for material/brand/variant. Spools already storeslicer_filament(the slicer preset's id) and K-profiles already carryfilament_id, but both were ignored — so a user's custom K-profile whose name doesn't agree with the slicer preset's name got silently dropped from the suggestion list even when the underlying filament_id was identical. #1689: ConfigureAmsSlotModal's K-profile filter (matchingKProfiles) ran the same name-only logic on the slot's selected preset — a spool assigned under "Generic PLA" with a custom K-profile actively bound on the printer landed in the modal as "K profile not assigned, default 0.020 will be used", while the printer-card hover-card correctly showed the active profile. The hover-card and the Configure Slot modal disagreed because they used different lookup paths; the modal's path was the one with the name-parse filter. The shared root cause: spool preset ids and K-profile filament_ids look different but are equivalent after normalisation. Spools storeslicer_filamentas the cloud setting_id form ("GFSG98_09" —_09is the variant suffix, the "S" infix marks it as a setting_id); K-profiles storefilament_idas the bare form ("GFG98"). Plain===doesn't match; both need normalising first. This conversion already exists in the other direction atbuildFilamentOptions(filament_id → "GFS" + filament_id.slice(2) for setting_id), so the inversetoFilamentIdhelper isn't speculative — it's just the matching reverse. Fix — one shared helper, two surfaces: new exports infrontend/src/components/spool-form/utils.ts—toFilamentId(id)normalises both shapes by dropping the "_NN" variant suffix and stripping the "S" in "GFS" (so both "GFSG98_09" and "GFG98" yield "GFG98");isGenericFilamentId(id)flags Bambu's genericGFx99ids (GFL99 = generic PLA, GFG99 = generic PETG, etc.) which are shared across many physical filaments and must NOT id-match (they over-match and obscure brand-specific profiles — name fallback handles those correctly). Then: (1)isMatchingCalibrationaccepts a newslicer_filament?: stringformData field, tries id-match first (with generic exclusion), falls through to the existing name parse —PAProfileSectionalready passes the fullformDataso no caller edit needed. (2)ConfigureAmsSlotModal.selectedPresetInfonow also resolves afilamentId(viatoFilamentId(cp.setting_id)for cloud presets;toFilamentId(builtinFilamentId)for builtin; empty for local/orca paths that fall through to name match);matchingKProfilesadds the id-match check at the top of the per-profile predicate, then keeps the existing name logic, then always unshifts the slot's currently-active K-profile (byslot_id === slotInfo.caliIdx, gated onactiveIdx > 0so caliIdx=0/null doesn't leak unrelated profiles in, and extruder-matched when known) — covers the #1689 case where the spool was bound under a generic preset but the active profile lives under a different filament_id entirely. The "always include active" branch is Spionkiller01's #1689 diff verbatim, gated more tightly. SpoolBuddy coverage: both K-profile surfaces in the kiosk UI reuse the shared components —SpoolBuddyWriteTagPagerenders<PAProfileSection>(auto-fixed viaisMatchingCalibration),SpoolBuddyAmsPagerenders<ConfigureAmsSlotModal>(auto-fixed viamatchingKProfiles). No kiosk-specific edits required; the shared helpers carry the fixes through. (SpoolBuddyCalibrationPageis scale calibration, unrelated;InventorySpoolInfoCardis display-only.) What this does NOT change: spools without a slicer_filament, K-profiles without a filament_id, and generic GFx99 ids all fall through to the existing name-based matching path — strictly additive precedence, no behaviour change for the name-only cases that already worked. The new id-match never causes a miss the old code would have caught. Tests: 21 new vitest cases —isMatchingCalibration.test.ts(18 cases) pins thetoFilamentIdround-trip in both directions (GFSG98_09 → GFG98 and back is identity-preserving for the cloud→K-profile flow), the genericGFx99exclusion, falsy/non-Bambu id pass-through (numeric local-preset id, Orca UUID), and the id-match-wins-over-name behaviour including the spool's reported"GFSG98_09" ↔ K-profile "GFG98"real-data scenario.ConfigureAmsSlotModal.test.tsx(3 cases) pins the modal-level behaviour: a custom K-profile name surfaces when filament_id matches (#1688 in-modal), the slot's active profile is always included even with no name/id match (#1689), and thecaliIdx == 0guard prevents unrelated profiles from leaking in via the safety net. Full frontend vitest suite: 2108 / 2108 green. ESLint clean on touched files; frontend build clean. Credit & dispatch: Spionkiller01 diagnosed both issues with concrete data (theGFSG98_09 ↔ GFG98normalisation case is theirs), tested both patches live on an H2C, and explicitly offered to PR. Landed verbatim with adjustments (shared helper, tighter active-profile guard) andCo-Authored-By. IndividualGhost1905 also reported #1689 independently and identified its connection to #1688. - Tabs no longer go silently zombie after the JWT expires — auth-expiry now redirects to /login on the same tab (#1698, reported by TCL987, fix patched in reporter's fork) — Reporter on X1C, Docker install, left a Bambuddy tab open past the 24 h JWT lifetime. After expiry: navigation between pages still worked, but every API request silently failed, leaving the UI looking like every list was empty. A manual refresh was needed to land on
/login. Root cause:AuthContext.userstays stale after the JWT clears. When a 401 with a token-invalidating message (Token has expired,Could not validate credentials,User not found or inactive,Invalid API key,API key has expired) lands infrontend/src/api/client.ts:154-167, the handler callssetAuthToken(null)to drop the token from sessionStorage / localStorage — butAuthContext.useris a React state value that was populated once at mount viacheckAuthStatus()→/auth/me, andsetAuthToken(null)doesn't reach into AuthContext's React tree.ProtectedRoute(App.tsx:101) only redirects whenuser === null, so the protected tree keeps rendering, every subsequent request goes out with no Authorization header, the backend 401s, and the UI shows nothing. A page refresh remountsAuthProvider,checkAuthStatus()finds no token,setUser(null)fires, the redirect runs — which is what the reporter ended up doing every 24 h. The 3 othersetAuthToken(null)call sites all live insideAuthContextitself and pair withsetUser(null)directly, so no cross-module signal was needed for them; theclient.ts:165site was the only one missing the React-tree notification. Fix (mirrors the reporter's fork patch deec96d): aftersetAuthToken(null)inclient.ts, dispatch awindow.dispatchEvent(new CustomEvent('auth:expired'))(guarded ontypeof window !== 'undefined'for SSR / test safety).AuthContext's mountuseEffectadds awindow.addEventListener('auth:expired', handleAuthExpired)listener whose handler callssetUser(null)after amountedRef.currentguard, and removes the listener in the effect's cleanup so unmount → remount doesn't double-bind.ProtectedRoutethen seesuser === nullon the next render and runs<Navigate to="/login" replace />immediately, no manual refresh needed. What this intentionally does NOT change: generic401 Authentication requiredresponses (without a token-invalidating message) still don't clear the token or fire the event — they're treated as transient timing issues, exactly asclient.ts:155's pre-existing comment documents. So a one-off 401 from a race during login won't redirect a working session. Listener cleanup means tests / dev hot-reload don't accumulate handlers. Tests: 4 new vitest cases —client.test.tsgains "dispatches 'auth:expired' event on 401 with invalid token message" and "does not dispatch 'auth:expired' on 401 with generic auth error" (both usevi.fn()listeners onwindowto assert the event fires/doesn't fire).AuthContext.test.tsxgains a newauth:expired event (#1698)describe block — "clears user when an auth:expired event is dispatched" simulates the login → expiry → event → user-null flow end-to-end viasetAuthToken('valid-token')(the canonical setter; writing to sessionStorage post-import wouldn't propagate to the module-levelauthTokenvariable initialised at import time), and "does not crash when the event fires after unmount" pins themountedRefguard so the listener can't trigger a state-update-after-unmount warning. Full frontend vitest suite: 2087 / 2087 green. ESLint clean on touched files. Frontend build clean. Credit to TCL987 for diagnosing this and shipping the working fix on their fork before opening the issue. - Filament usage no longer over-counts when printing one plate from a multi-plate 3MF (#1697, reported by volodymyr-doba) — Reporter on P1S printed a single lid (~190 g grey PETG) from
gridfinity-storage-box-5x4x6.gcode.3mf(a multi-plate file with 5×box + 5×lid plates) and the spool's Usage History recorded 242 g of grey + 31 g of black — the whole file's filament total, not the dispatched plate. The print took 5 h 47 m which matches the lid alone, and the queue card correctly previewed 190 g, but the spool got debited for everything. Root cause: usage tracking parsed the 3MF without a plate filter.extract_filament_usage_from_3mf(file_path, plate_id)inbackend/app/utils/threemf_tools.pyalready supports filtering and the queue's pre-flight capacity check atapi/routes/print_queue.py:254/:286passesitem.plate_id, but the two completion-time recorders did not:_track_from_3mfinservices/usage_tracker.py:907(internal Filament Inventory) andstore_print_datainservices/spoolman_tracking.py:223(Spoolman mode) both called the extractor with no plate_id and summed every plate. Perfeedback_inventory_modes_parityboth modes had to ship in the same drop, AND per the verification pass after the initial implementation: the direct-Print path (api.reprintArchive/api.printLibraryFilewithplate_id: selectedPlateinPrintModal/index.tsx:739/750) hits the same bug because it never goes through the queue — caught before merge by tracing the frontend dispatch surface end-to-end. Fix — two complementary captures: (1)PrintSessiongains aplate_id: int | Nonefield;on_print_startqueriesPrintQueueItemfor the printer's currently-printing row and recordsqueue_item.plate_idonto the session — covers the queue path. (2)register_expected_printinmain.pyaccepts a newplate_idparameter and stores it in a parallel_print_plate_ids: dict[int, int]dict (mirror of_print_ams_mappings);background_dispatch.py's 2 register sites andprint_scheduler.py's 1 register site now pass plate_id (the dispatch already resolved it via_resolve_plate_id; reordering the resolve to run before register is a no-op since the resolver is pure). At expected-print promotion,main.pyinjects_print_plate_ids[archive_id]into_active_sessions[printer_id].plate_id(only when the session has no plate_id yet — queue captures win), mirroring the existingams_mappinginjection pattern. The dict drains onon_print_completeand on TTL eviction of the matching_expected_printsentry — same lifecycle as_print_ams_mappings. (3)_track_from_3mfaccepts a newplate_idkwarg, threads it fromsession.plate_id, and passes it toextract_filament_usage_from_3mf. (4)store_print_dataaccepts aplate_idkwarg; the 3 call sites inmain.pypass_get_start_plate_id(archive_id)(new helper, parallel to_get_start_ams_mapping); withinstore_print_datathe caller value wins, falling back toqueue_item.plate_idfor the queue path. The PrintArchive'sfilament_used_gramsstays file-level summed by design (#1593's contract — the archive describes the file, not the run); only the per-run usage attribution becomes plate-aware. What this intentionally does NOT touch: for direct Print of a single-plate file,_resolve_plate_idreturns 1 → registered asplate_id=1, which extracts plate 1 = the whole file — identical to the prior no-filter behaviour. The change is observable only for multi-plate 3MFs where a specific non-first plate was dispatched. Tests: 9 new acrosstest_usage_tracker.py+test_spoolman_tracking.py+test_print_start_expected_promotion.py— plate_id propagation through_track_from_3mf; absence leaves itNone; on_print_start captures queue_item.plate_id; on_print_start no-op when no queue item; Spoolman-mode plate-scoped extract;register_expected_printstoresplate_idin_print_plate_ids;_get_start_plate_idreads it back; injection into session for direct-Print (no queue capture); guarded against overwriting an already-captured queue plate_id. The pre-existingtest_prefers_explicit_ams_mapping_over_queue_mappingupdated for the new unconditional queue lookup (was conditional, now always queries to capture plate_id). Full 5830-test backend suite green. Ruff clean across the entire backend, not just touched files. - AMS slots with a spool loaded but no material configured now show "?" instead of "Empty" (#1694, reported by kleinwareio) — On a 3-AMS P1S the reporter's screenshot showed AMS-C slots labelled "Empty" even though spools were physically loaded; OrcaSlicer's Device view showed the same slots as loaded. Root cause: the compact label below the AMS slot circle in PrintersPage rendered
tray.tray_type || t('ams.slotEmpty'), falling back to "Empty" whenever the printer firmware hadn't been told which material is in the slot. The codebase already had agetEmptySlotKindhelper that distinguishes'physical'(firmware confirmed empty via state 9/10) from'reset'(tray_type absent but firmware hasn't confirmed empty — i.e. spool loaded, just unassigned). The hover-card / circle border already used that distinction (line 814+ comment); the compact label did not. Fix: label now branches onemptyKind—'physical'keeps "Empty" (the firmware-confirmed empty case),'reset'shows "?" (matching the slicer's own convention for "loaded but unknown material"). External / VT tray label is unchanged (external trays have no "configured/unconfigured" distinction — they're either loaded or not). The SpoolBuddy kiosk'sAmsUnitCardwas carrying the same bug and got the same fix (mirror ofgetEmptySlotKind, "?" vs "Empty" label, tooltip "Spool loaded — slot not configured"). i18n: newams.slotUnconfigured: '?'key added to all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) — value is universal so it's identical in every locale. The existingams.emptySlotReset = 'No filament assigned'tooltip surface in FilamentHoverCard already covers the "what does this mean" question on hover, so no new tooltip key needed for the main card. Tests: AmsUnitCard vitest gainsshows "?" for loaded-but-unconfigured slot (#1694)pinning both branches in one render (one slot withstate: 9→ "Empty", one with no state → "?"). Existing AMS tests stay green (9/9 SpoolBuddy AmsUnitCard; AMS load/unload page tests untouched and green); i18n parity green (5100 leaves × 11 locales); frontend build clean; ESLint clean. - Virtual Printer MQTT no longer disconnects idle OrcaSlicer at keepalive×1.5 (#1548 round 2, reported by hollajandro) — Round 1 (commits b663605 + 4ffefa6) shipped the keepalive parser + 1.5× idle disconnect per MQTT spec §4.4 and a per-minute status-push diagnostic. Reporter's follow-up pcap proved the round-1 logic was correct as designed, but exposed the actual root cause: the same OrcaSlicer install which stays connected to a real Bambu P1S indefinitely sends zero MQTT packets after the initial CONNECT / SUBSCRIBE / pushall / get_version burst — no PINGREQ at all — so any §4.4-compliant server disconnects it at
keep_alive × 1.5. Real Bambu firmware does not enforce §4.4 (verified: the reporter's identical Orca install holds an idle session against real hardware on the same network), so spec compliance is itself the regression. Fix: after CONNECT/auth, drop the application-level read timeout entirely (read_timeout = None) and setSO_KEEPALIVEon the underlying socket so the OS TCP stack detects truly dead connections within a few minutes. The 60 s pre-CONNECT timeout is preserved — a client that opens TCP but never sends CONNECT still gets reaped to prevent half-open resource leaks. Negotiated keepalive is still parsed and now logged at INFO ("MQTT client … authenticated (negotiated keepalive=Xs, idle disconnect disabled)") for support-bundle visibility. Tests: TestHandleClientIdleConnection addstest_idle_client_stays_open_past_one_and_a_half_times_keepalive(negotiates keep_alive=2, sits idle for 4 s, asserts handler still running and writer not closed — direct round-1 inversion),test_so_keepalive_set_on_socket_after_connectpinssetsockopt(SOL_SOCKET, SO_KEEPALIVE, 1)runs on the wrapped socket the moment auth succeeds. PINGREQ test docstring updated since there's no longer a timeout for it to "reset". All 33 VP MQTT server tests green; ruff clean. After this ships, OrcaSlicer should stay connected to the VP indefinitely while idle and reconnect cleanly on real network drops. - System page now reports the container's uptime / boot time, not the host's (#1690, reported by IndividualGhost1905) — Reporter on Proxmox LXC observed that System → Uptime / Boot Time matched the Proxmox host's values, not the container's. Root cause:
psutil.boot_time()reads/proc/stat:btime, which on shared-kernel containers (Docker, LXC) is the host kernel's boot time — leaking the host's lifecycle into Bambuddy's UI. Fix: read PID 1's create_time instead —psutil.Process(1).create_time()returns the POSIX timestamp of the init/entrypoint process, which in a container is the container's start time, and on bare metal / VMs is the host init (effectively identical topsutil.boot_time()within a sub-second). Defensivepsutil.Error/OSErrorfallback to the oldpsutil.boot_time()for the rare case where /proc/1/stat is unreadable (locked-down container, custom seccomp policy). No frontend / i18n change — the field shape is unchanged, only the value is now correct on container installs. Tests: 2 new integration cases — one pins that the route readsProcess(1).create_timeand that the response uses that timestamp (notboot_time), the other pins the fallback path via a realpsutil.NoSuchProcess(1)so the endpoint still returns 200 with the best-available answer. All 8 pre-existing system-info tests updated to also mock the new code path; full system API suite 20/20 green; ruff clean. - Profile editor filament type dropdown now lists PLA-CF and the other Bambu CF / GF / specialty materials (#1686, reported by Bgabor997) — Creating or editing a filament preset on the Profiles page (BL Cloud, Orca Cloud, and Local Profiles all open the same shared editor) only offered 11 base materials (PLA, ABS, PETG, TPU, PA, PA-CF, PET-CF, PC, ASA, PVA, HIPS). Reporter on P1S wanted to tag a custom preset as PLA-CF — the dropdown source had no entry, so the saved preset's
filament_typewas wrong and the printer received the wrong material code at dispatch. Root cause:backend/app/data/filament_fields.json(served byGET /cloud/fields/filamentand consumed byProfilesPageviagetCloudFields) shipped a curated subset that pre-dated Bambu's CF/GF lineup expansion. Other surfaces in the codebase already named the canonical list (utils/filament_ids.pyGENERIC_FILAMENT_IDS,spool-form/utils.tsMATERIALS, the Bambu filament-id catalog incloud.py), so the gap was specifically in the editor's allowed-values JSON. Fix: expanded thefilament_typeselect to 25 BambuStudio-aligned options grouped by family — PLA (+ CF/GF/AERO), PETG (+ CF), ABS (+ GF), ASA (+ CF/GF), PC, PCTG, PA family (+ CF/PAHT-CF/PA6-CF/PA6-GF), PET-CF, TPU, PPS family (+ CF/GF for X1E), PVA, HIPS. No frontend, no i18n (material codes are universal). K-profiles editor unaffected — it picksfilament_id, notfilament_type. Tests: 15 unit cases intest_filament_fields_options.pypin every newly-added variant (PLA-CF, PLA-GF, PLA-AERO, PETG-CF, ABS-GF, ASA-CF, ASA-GF, PCTG, PAHT-CF, PA6-CF, PA6-GF, PPS, PPS-CF, PPS-GF) plus the baseline-must-still-be-present guard so a future curation pass can't silently drop them. - Native systemd install no longer fails when INSTALL_PATH is under /home (#1685, reported by Geoff-S) —
bambuddy.serviceshipped withProtectHome=true, which makes/home/*invisible to the service namespace. When the user installed into/home/bambuddy/(instead of the default/opt/bambuddy/), theExecStart=/home/bambuddy/venv/bin/uvicornpath couldn't be resolved at exec time and the unit failed withstatus=203/EXEC: Unable to locate executable. TheReadWritePaths=$INSTALL_PATHdirective doesn't reliably re-expose/home/*subpaths for executable resolution. Fix:install/install.shnow detectsINSTALL_PATH == /home/*and emitsProtectHome=read-onlyfor that case; the default/opt/bambuddy/install keeps the stricterProtectHome=true. The manualdeploy/bambuddy.servicetemplate defaults toProtectHome=read-onlywith a comment explaining when to tighten it totrue.read-onlykeeps/homeimmutable to the service (no security regression — the service can read its venv but not write anywhere outside theReadWritePathsallowlist). - VP settings card now shows the target printer's serial in proxy mode — On a proxy-mode VP, the runtime services (SSDP advertisement, MQTT bind identity, certificate subject) all use the target printer's actual serial via
target_printer_serial or self.serial(manager.py:235, 941, 957), but the/api/v1/virtual-printersresponse — which feeds the VP settings card — always returned the self-generated suffix-based serial from_get_serial_for_model(model_code, vp.serial_suffix). The card therefore displayed a serial that didn't match what the bridge actually advertises and what the slicer sees, breaking the visual "one identity per VP" mental model. Fix:_vp_to_dict(api/routes/virtual_printers.py:77) is now async and acceptsdb; whenvp.mode == VP_MODE_PROXY and vp.target_printer_id, it issues a singleSELECT serial_number FROM printers WHERE id = vp.target_printer_idand substitutes the result into the responseserialfield. Archive / queue / review modes keep the self-generated serial — those modes synthesise their own identity and never speak the target's. Defensive fallback when the target row is missing (printer deleted mid-config, manual SQL tweak, race between delete-printer and read-VP): the response falls back to the self-generated serial so the card still renders and the user can fix the target, rather than the API 500-ing. All 4_vp_to_dictcall sites (list, create, get, update) updated toawaitwithdb. Tests: 3 new inTestVirtualPrinterSerialSurface— proxy VP returns target serial across all three response paths (create / get / list), non-proxy VP with a target still uses the self-generated serial, orphaned proxy VP falls back to self-generated. Full VP API suite stays green (34/34); VP unit suite stays green (126/126); ruff clean.
Added
- Inventory page now supports native CSV import / export (#1576, PR #1659 by samedyuksel) — Bulk-add spools without manually clicking through the form, and back up / migrate the local inventory in a single round-trip. Export downloads
bambuddy-spools-YYYY-MM-DD.csv(header + one row per active spool); Import shows a preview table that classifies each row as valid / error / skipped before anything hits the database, then a confirm click persists only the valid rows in one transaction (invalid rows are skipped, the user fixes them and re-uploads). Local inventory only — in Spoolman mode the buttons render disabled with a tooltip pointing at Spoolman's own CSV import/export, since the Spoolman backend has its own data store. Schema: fixed 18 columns, case- and whitespace-tolerant headers, includesweight_used,last_used, and the SpoolCreate fieldsstorage_location/category/low_stock_threshold_pctso the round-trip preserves the per-spool location data from #1291.remainingis a derived, export-only column (label_weight - weight_used, clamped at 0) — it's written for human readability and ignored on import (weight_used is the source of truth, accepting both would let them contradict). Colour resolution: explicitrgbawins, otherwisebrand + color_nameresolves against the Color Catalog (case-insensitive, single in-memory pass — no N+1); a catalog entry withmaterial = NULLis treated as the project's "matches any material" convention so a generic match counts as exact rather than firing the cross-material warning. Validation reusesSpoolCreateso every constraint that already protects manual adds (weight_used >= 0,weight_used <= label_weight,low_stock_threshold_pctrange, etc.) protects bulk imports too. Hardening: 5 MB upload cap with a structuredcsv_import_too_large413 response — Bambuddy doesn't have a global HTTP-level cap so the check lives on the route, and the implementation is a bounded 64 KB chunked read that bails the moment the accumulated body crosses the cap (file.size isNonefor chunked uploads so the loop is what actually prevents the OOM, not the pre-check). Spreadsheet formula-injection guard: every exported cell starting with=/+/-/ `` / tab / CR is prefixed with a single quote on export, and the inverse strip on import keeps the round-trip lossless instead of accumulating quotes on every cycle. Soft-warn surface in the preview: aduplicate_of_existingflag fires when an active spool with the same material + brand + color_name exists (single SELECT, no N+1) so a double-click or re-upload of the same CSV doesn't silently duplicate the inventory — the row still imports (Spool has no unique constraint, by design), but the preview renders a Copy icon + tooltip so the user knows. Frontend: new `SpoolCsvImportModal` (file pick → preview table with per-row status / colour swatch / warnings → confirm imports valid rows) wired to Import + Export buttons on the inventory header; swatch rendering uses the existing `getSwatchStyle` helper so alpha=00 shows the checkerboard underlay instead of rendering as solid black, matching the rest of the inventory surface. i18n: new `inventory.csv` namespace with full translations in all 11 locales (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW). Tests: 25 backend integration cases pin every behaviour — export shape, import dry-run vs real, color resolution (catalog hit, explicit rgba wins, cross-material flagged, exact-material match, generic-material match not flagged), 5 MB rejection, weight_used bounds, formula-injection round-trip without quote accumulation, dated filename, extra-column round-trip, duplicate-warn flag. Plus 3 frontend modal tests. Full backend suite + ruff + ESLint + frontend build + i18n parity (5092 leaves × 11 locales) green. Companion docs: wiki PR maziggy/bambuddy-wiki#41 documents the schema, behaviour, and the Spoolman-mode disabled-with-tooltip semantics.
Fixed
- Print modal now exposes a "Nozzle Offset Calibration" toggle for dual-nozzle printers (#1682, reported by louiskleiman) — Reporter on H2D running diamond nozzles: BambuStudio exposes a per-print "Nozzle Offset Calibration" option that is incompatible with diamond hot ends, but Bambuddy had no way to control the same flag, so every dispatch silently set it to the firmware default. Root cause: the field was hardcoded.
bambu_mqtt.py:3445always wrote"nozzle_offset_cali": 2(skip) into the MQTTproject_filepayload, regardless of model, regardless of any user choice. The wire format is tri-state —1=run,2=skip — and matches BambuStudio's encoding; the manual-calibration route (/printers/{id}/calibration) already wired the correspondingcali_idx=2MQTT command, but the dispatch-time toggle was simply absent. For most users this was invisible (BambuStudio's default is "run" on H2D / H2D Pro / H2C / X2D, Bambuddy's default was effectively "skip"), but a diamond-nozzle setup that needs the calibration explicitly off had no way to confirm Bambuddy's behaviour or override it the other way once we add a toggle that follows the slicer's default. Fix: end-to-end plumbing ofnozzle_offset_caliwith a hard MQTT-layer gate on dual-nozzle.start_print()(bambu_mqtt.py:3300) gains anozzle_offset_cali: bool = Falsekwarg and the project_file payload line becomes"nozzle_offset_cali": 1 if (nozzle_offset_cali and is_dual_nozzle) else 2. The dual-nozzle check reusesis_dual_nozzle_model()and the runtime_is_dual_nozzleflag (set whendevice.extruder.infohas ≥ 2 entries) — same canonical signal the rest of bambu_mqtt.py uses for routing decisions. Even if a stale queue item from when the printer was misidentified carries the flag, the MQTT layer downgrades it to2so firmware never tries to calibrate a head it doesn't have. The kwarg threads throughprinter_manager.start_print(), bothbackground_dispatchcall sites, andprint_scheduler._start_printso every dispatch path — direct reprint, library file, queue-dispatched, watchdog-recover — respects the per-item setting. Persistence:print_queue.nozzle_offset_calicolumn (BOOLEAN DEFAULT TRUE, branched onis_sqlite()because Postgres rejectsDEFAULT 1for BOOLEAN, caught by my Postgres test environment before this shipped) — default TRUE matches BambuStudio's behaviour on dual-nozzle, the MQTT gate makes the value a no-op on single-nozzle. Newdefault_nozzle_offset_calisetting (default TRUE) plumbed throughschemas/settings.py, the settings PUT allowlist, and the SettingsPage card — the row in Settings → Default Print Options only renders whenprinters.some(p => p.nozzle_count === 2), so single-nozzle-only users never see a control they can't act on. ReprintRequest + FilePrintRequest schemas (schemas/archive.py,schemas/library.py) carry the field too so the API surface is consistent across the three "send 3MF to printer" routes. Frontend:PrintOptionsPanel(components/PrintModal/PrintOptions.tsx) accepts ashowDualNozzleOptionsprop and filters the option list;PrintModal/index.tsxcomputes it fromselectedPrinters.some(p => p.nozzle_count === 2)in printer-mode or from a small inlineDUAL_NOZZLE_MODELSset in model-mode (mirrors the backendDUAL_NOZZLE_MODELSfrozenset:H2D,H2DPRO,H2C,X2D). The same gate flows throughQueuePagebulk-edit — the new tri-state toggle only renders if any registered printer hasnozzle_count === 2. Labels reuse the existingsettings.defaultBedLevelling/settings.defaultFlowCali/ etc. translation keys (identical strings, already translated) to keep i18n churn proportional to the actual new copy. i18n: 3 new keys per locale × 11 locales = 33 entries —settings.defaultNozzleOffsetCali,settings.defaultNozzleOffsetCaliDesc,queue.bulkEdit.nozzleOffsetCali— real translations in every locale (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallbacks. i18n parity check confirms 5069 leaves × 11 locales. Tests: 4 new intest_bambu_mqtt.pypin the four-quadrant gate: default value (P1S, no kwarg →2), single-nozzle ignore (P1S, kwargTrue→ still2— the safety net), dual-nozzle honour (H2D,True→1), dual-nozzle false (H2D Pro,False→2— the diamond-nozzle case).test_printer_manager.pyupdated for the new kwarg inassert_called_once_with. Frontend tests: existing PrintModal / QueuePage / SettingsPage suites pass with the new field threaded through (117 / 117). Full backend suite: 3840 / 3840 pass. ruff clean; frontend build clean; ESLint clean; i18n parity green. - "Assign Spool" no longer claims the AMS slot was configured when it wasn't (#1680, reported by kleinwareio) — Reporter clicked Assign Spool from the printer card for AMS-B slot 4 while that slot was empty. The toast said "Spool assigned and AMS slot configured" but the AMS card kept showing slot 4 as Empty. Root cause: misleading toast on the empty-slot deferred-config path. The backend (
inventory.py:1385-1405) deliberately skips the MQTTams_filament_settingpublish when the AMS reports an empty tray state (state ∈ {9, 10}) because Bambu firmware silently drops the push for empty slots — there's no point sending a command the printer will discard. The assignment row is persisted withpending_config=true, andon_ams_change(main.py:1031-1054) re-fires the full configuration the moment the AMS reports a non-empty fingerprint in that slot. The flow is correct; the success log linePre-configured assignment: spool 16 → printer 1 AMS1-T3 (slot empty, will configure on insert)confirms the backend did exactly that. But the frontend ignored the response flag.AssignSpoolModal.tsx:153always calledshowToast(t('inventory.assignSuccess'), 'success')— the wording "Spool assigned and AMS slot configured" — regardless of whether the backend actually configured the slot or deferred. The sibling SpoolBuddy modal (spoolbuddy/AssignToAmsModal.tsx:212-226) already branched onpending_configand showed a distinct "Slot will configure when you insert the spool" message; the printer-card modal was just never updated to match. Fix:AssignSpoolModal.tsxnow readsnewAssignment.pending_configand picks between'inventory.assignSuccess'(slot configured immediately) and the new'inventory.assignPendingInsert'("Assigned. Slot will configure when you insert the spool.") key. Spoolman-mode branch unchanged — the Spoolman backend route always sends the MQTT push (no pending_config flag is exposed) and the SpoolBuddy modal's existing comment documents that. i18n: newinventory.assignPendingInsertkey in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), translations copied verbatim from the existing parallelspoolbuddy.modal.assignPendingInsertentries so the message reads identically across the app. No English fallbacks per the project's hard rule; i18n parity check confirms 5066 leaves × 11 locales. Tests: 2 new inAssignSpoolModal.test.tsx—shows the pending-insert toast when backend returns pending_config=true (#1680)pins the new branch (slot-was-empty case the reporter hit), andshows the configured toast when backend returns pending_config=false (#1680)is the counterpart regression guard so a future refactor can't silently mark every assign as pending. Both also assert the WRONG toast is NOT also called (defense against accidental double-toast). 16/16 AssignSpoolModal tests pass; frontend build clean; ESLint clean. - Restarting Bambuddy mid-print no longer marks the live archive as "cancelled / aborted" + duplicates it + double-counts filament (#1679, reported by IndividualGhost1905) — Reporter on X1C, daily build
v0.2.5b1-daily.20260607: a print was running, the host was restarted (planned reboot / power outage / watchtower image update), and Bambuddy's printer card showed the print as cancelled while the printer continued printing happily. Print log showedabortedfor that row, filament usage was deducted at the cancellation moment (48.6 g / 5 % in the supplied screenshots), and when the print actually finished a second archive was created and filament was deducted again. Net effect: filament inventory off by the entire print weight, statistics showing one "user-cancelled" entry alongside one "completed" entry for the same physical print. Second confirmed hit from the same reporter, plus a corroborating comment from Arn0uDz on watchtower-driven restarts. Root cause: connected-edge reconciliation fired on a bare MQTT-connected state that had no real data yet. On Bambuddy startup, a freshBambuMQTTClientis constructed withPrinterStatedefaults — most importantlystate.state = "unknown"andstate.subtask_name = "". The MQTT_on_connectcallback (bambu_mqtt.py:668-669) broadcastson_state_change(self.state)immediately after the broker accepts the connection — BEFORE the_request_push_allround-trips with the printer's real status.on_printer_status_change(main.py:825) seesstate.connected=Trueflip on the connected-edge, spawnsreconcile_stale_active_printsfor that printer. The reconcile walks every archive instatus="printing", calls_is_active_archive_stale(main.py:3352) — which seesstate.state="UNKNOWN"(skips the IDLE/FINISH/FAILED branch), thenstate.subtask_name=""(matches trigger 3, "printer subtask_name empty") and returns stale. A synthesisedabortedPRINT COMPLETE fires for every in-flight archive on every printer, clears_active_prints, and when the real PRINT COMPLETE finally arrives at print end,_active_printsdoesn't have the entry, so a brand-new archive row is created instead of overwriting the synthesised one. The pre-existing comment at_is_active_archive_stale("the next real PRINT COMPLETE would have overwritten the status anyway") was wrong: the reactive completion handler uses_active_printsfor lookup, not a join on filename/subtask_id, so the original row stays cancelled and a duplicate is born. Timing-dependent in practice — on hosts where the printer's firstpush_statusresponse wins the race against the reconcile background task, state is real and reconcile doesn't false-positive; on slower hosts or busy MQTT brokers, the bare-connect-edge fires first and the bug hits. The reporter is on a slower-race host and saw it twice. Fix: two-layer guard. (1) Primary:on_printer_status_changenow gates the reconcile spawn onstate.statebeing a real value —state_known = bool(state.state) and state.state.upper() not in ("", "UNKNOWN")— so reconcile doesn't fire until the firstpush_statusupdatesstate.stateto a real Bambu firmware value (RUNNING / IDLE / FINISH / PREPARE / SLICING / PAUSE / FAILED). When that real push arrives,on_printer_status_changefires again, the connected-edge flag is stillFalse(we never set it), and reconcile runs against actual evidence. The existing #1542 mechanism — synthesising a missed PRINT COMPLETE for prints that finished during a disconnect window — keeps working: if the printer reportsIDLEon its first real push after reconnect, reconcile catches it the way it always did. (2) Belt-and-braces:_is_active_archive_stalenow returns(False, "")whenstate.stateis empty /"unknown"/None, regardless of the subtask fields. Strictly more conservative than the previous behaviour; only suppresses the degenerate-input false positive. Any future caller that bypasses the primary gate still can't synthesise an aborted completion from defaults. Tests:test_reconcile_stale_active_prints.py26 cases (up from 21) — new parametrizetest_pre_push_state_returns_not_stale_even_with_empty_subtaskpins all five degenerate forms ("unknown","UNKNOWN","Unknown","",None) and asserts none triggers stale even with emptysubtask_id+ emptysubtask_name. The existing #1542 regression coverage stays green — terminal-state, subtask-id-mismatch, and empty-subtask-name-under-RUNNING all still report stale on real state pushes. Full backend suite: 3836 / 3836 pass. ruff clean. - Print queue no longer wedges in "Currently Printing" when a printer accepts
project_filebut never starts (#1678, reported by kleinwareio) — Reporter on two P1S, one was power-cycled mid-print and came back online; from then on Bambuddy showed the next queue item as "Currently Printing" at 0% while the printer card showed "Idle / Ready to print". The same file also re-appeared in the Queued list as Pending after the user resubmitted. Only restarting the Bambuddy container ever recovered it. Support log + screenshots confirm: at dispatch time MQTTproject_filewas ACK'd, printer pushedgcode_state=IDLE, gcode_file=<our-file>, subtask_id=<our-submission-id>— i.e. the file landed on the printer but the printer never transitioned IDLE → PREPARE → RUNNING. Root cause:_watchdog_print_startreturned SUCCESS as soon assubtask_idadvanced. The subtask_id-as-pickup-signal was added for H2D, which can sit atFINISHfor ~50 s after acceptingproject_filebefore flipping to PREPARE (#1078) — but it's strictly a "command landed" signal, not "actually printing". When the printer accepts the file but then wedges (cloud+LAN re-auth dance after a power cycle, old firmware, partial network outage), the watchdog returned success, the queue row stayed atstatus='printing', the in-memory_expected_printsentry stayed registered (TTL is 2 hours and only clears the dict, not the DB row), and every subsequent queue item was blocked because the printer was still "in flight". This reporter's firmware (01.07.00.00, current is 01.08.x+) andbambu_cloud_token-enabled cloud+LAN mode make the post-power-cycle wedge measurably more likely on their box, but the queue-wedge bug applies to any printer that accepts a file but stalls before starting. Fix: split the watchdog into two phases. Phase A (up totimeout, default 90 s, unchanged behaviour) waits for either an active-state transition OR asubtask_idadvance — if neither happens the publish was lost on a half-broken MQTT session (#887/#936) and we revert + force-reconnect (the original #967 recovery path). Phase B (new, up tophase_b_timeout, default 180 s) only runs when Phase A exited via subtask_id-alone: keep watching for the active-state transition. 180 s is ~3.5× the worst observed H2D FINISH → PREPARE delay (#1078), so the H2D path stays green. If Phase B times out the queue item is reverted topendingso the user can retry without restarting Bambuddy — and Phase B explicitly does NOT force a MQTT reconnect because subtask_id-advance proves the project_file landed and a forced reconnect mid-parse triggers 0500_4003 (#1150). Phase A's existinggcode_file-changed discriminator (#1150) stays put for the no-subtask-id-advance case. Tests:test_scheduler_watchdog.py14 cases (up from 13) — the #1078 H2D regression test rewritten to step the status through Phase A (subtask_id advance with state=FINISH) then Phase B (state flips to RUNNING) and pin success; newtest_reverts_when_subtask_advanced_but_state_never_activepins the #1678 wedge case (subtask_id advances, state stays IDLE for the full Phase B window → revert + NO force_reconnect call); newtest_default_phase_b_timeout_is_180_secondspins the new default so a future refactor doesn't silently shrink the H2D headroom. Existing #967 / #1150 / #1370 / disconnect / fallback / discriminator regression coverage all stays green. Wider scheduler + queue + dispatch test surface (305 tests) stays green; ruff clean. - Service-worker activate handler no longer hangs first-install browsers (demo site stuck spinner + Firefox Corrupted-Content) — Reproduced live on the demo platform: a visitor lands on
{session}.demo.bambuddy.cool/, the Printers page renders, but clicking any sidebar entry sticks the next page on a spinner; only a manual reload recovers. In Firefox the same race surfaces as a "Corrupted Content Error" withsw.jsstuck inactivatingfor the entire session. Root cause: theclient.navigate(client.url)call added to theactivatehandler insw.js(commit18d534c9, shipped 2026-06-04 alongside the Orca Cloud landing) was intended to force kiosks running an old SW to reload after a deploy, but its only guard wasclient.url && typeof client.navigate === 'function'— neither distinguishes a first install from an upgrade. On every fresh origin (every demo session is a new subdomain, but also any browser visiting Bambuddy for the first time, or after clearing site data) the activate handler still fired the forced navigation: Chromium raced it against React Router's in-flight SPA mount and wedged the page; Firefox'sevent.waitUntildeadlocked onawait client.navigate(...)because the SW intercepts its own document fetch while stillactivating, the document load aborts, and the SW never reachesactivated. The "first install on a never-controlled client" guard the commit's comment claimed simply didn't exist in code. Fix: split the lifecycle correctly.sw.jsactivate handler is reduced to cache cleanup +clients.claim()(matches the standard PWA lifecycle and lets activation complete in low single-digit ms regardless of in-flight document state). The deploy-pickup reload moves tosw-register.js: capturehadController = !!navigator.serviceWorker.controllerat script load (true ⇔ a previous SW was controlling the document), listen forcontrollerchange, and onlylocation.reload()whenhadControllerwas true. A returning kiosk hits a new deploy → had a controller → reloads as before. A first-install visitor (no prior SW, or hard-refresh, or first demo session) → no controller → no forced navigation → React mount completes cleanly.CACHE_NAMEbumpedbambuddy-v29 → bambuddy-v30andSTATIC_CACHEbambuddy-static-v28 → bambuddy-static-v29so existing browsers fetching the newsw.jsdrop the old CacheStorage in the same pass — without the bump the SW file byte content might equal the cached one and the upgrade installs nothing. The SpoolBuddy-kiosk unregister branch at the top ofsw-register.jsis unchanged (still wipes registrations on/spoolbuddypaths). Thenotificationclickhandler insw.js(open-tab-on-push) still usesclient.navigate(url)— different code path, unrelated, unchanged. - VP archive/queue names with
&no longer render as&amp;+ tooltip corrected for BambuStudio 2.7.x reality (#1658 follow-up, reported by IndividualGhost1905) — Two bugs surfaced on the same screenshot set: (A) Metadata-mode archive and queue names showedPCB Vise &amp; Solder Stationwhere the 3MF's Title metadata isPCB Vise & Solder Station. Root cause:ThreeMFParser._parse_3dmodel(backend/app/services/archive.py:495-538) parsed the XML<metadata name="Title">…</metadata>payload via regex and stripped whitespace but never calledhtml.unescape(). The raw&landed in the DB; React then auto-escaped the&again on render, producing&amp;. The sibling parserProjectPageParser(line 754) already had a loop-until-stable unescape and a comment explaining why ("content is often triple-encoded" — observed BambuStudio behavior), the makerworld-fields path just didn't share it. Fix: module-levelimport htmland the same loop-until-stable unescape pattern in_parse_3dmodel, applied uniformly to all<metadata>values soTitle,Designer, and any future fields all get peeled the same way. The loop terminates as soon ashtml.unescape()stops changing the string, so single-, double-, and triple-encoded payloads all converge to the correct value; plain ASCII passes through untouched. (B) Filename-mode showed the slugified project title (PCB_Vise_&_Solder_Station) instead of the user-typed Send-dialog text ("Main Parts"). This is NOT a Bambuddy bug — BambuStudio source confirms it.PrintJob.cpp:314-325(src/slic3r/GUI/Jobs/PrintJob.cpp) readsBBL_DESIGNER_MODEL_TITLE_TAG(defined as"Title"inbbs_3mf.hpp) from the 3MF, slugifies it (space →_, unusable chars<>[]:/\|?*"→_, collapse runs of_, truncate to 100 chars), and unconditionally overwrites the user-typedm_project_namewith it before sending.params.project_namebecomes both the FTP filename and the MQTTsubtask_name. The user-typed string never leaves BambuStudio when a Title metadata exists — there is no MQTT field carrying it, so Bambuddy has no recovery path. The previous tooltip ("handy if you renamed the job in the 'send to printer' dialog") promised something BambuStudio strips, and the previous reply to the reporter dismissed this as "OrcaSlicer-style upload, working as designed" which was wrong on BambuStudio 2.7.1.57. Fix: tooltip rewritten in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) to spell out the BambuStudio behavior — both modes often produce the same string because BS overwrites the Send-dialog name with the 3MF Title field when present. Tests: 3 new intest_archive_service.py::TestThreeMFMetadataHTMLUnescape—Titlewith&unescapes to&(the reporter's exact case),Titlewith triple-encoded&amp;amp;peels all three layers (the BambuStudio worst-case ProjectPageParser already documents), plainTitle=Benchypasses through unchanged (regression guard against accidentally munging non-encoded payloads). Full 104-test archive suite green; ruff clean; i18n parity holds (5065 leaves × 11 locales); frontend build clean. - FTP passive-port pool now sliced per-VP (10 ports each) so bridge-mode Docker drops from ~3.5 GB to ~210 MB host RAM (#1646, reported by TheFou — followed up with corrections we acted on) — Reporter on a Linux Docker VM (
network_mode: hostnot viable because other containers already bind the same ports) measured 2002docker-proxyhost processes spawned from the previously-exposed50000-51000:50000-51000range — one process per port per address family, ~3.5 MB RSS each, ~3.5 GB total that doesn't show up indocker statsbecause it's host-level not container-level. Root cause: shared port pool, treated as symptom not cause.VirtualPrinterFTPServerexposedPASSIVE_PORT_MIN/MAXas class constants (backend/app/services/virtual_printer/ftp_server.py:573-574), so every VP's FTP session passed the same(50000, 51000)range into_bind_passive_portand competed on the same 0.0.0.0 binds. The widening from 100 → 1001 ports in an earlier round had been collision-avoidance headroom for multi-VP-on-shared-bind, but the cost was paid by every install — including the reporter's single-VP install that only ever needed ~10 ports of headroom. Fix: per-VP non-overlapping slices, allocated by VP id. New module-levelcompute_passive_port_slice(vp_id) → (port_min, port_max)returns a 10-port window: VP id 1 → 50000-50009, VP id 2 → 50010-50019, …, VP id 100 → 50990-50999. Class constants are gone;VirtualPrinterFTPServer.__init__now takespassive_port_min/passive_port_maxinstance args.manager.pycomputes the slice at server-construction time fromself.idand passes it in. Result for the reporter (single VP): 10 exposed ports → 20 docker-proxy processes → ~70 MB instead of ~3.5 GB. Three VPs → 30 ports → ~210 MB. Wrap-around behaviour pinned: VP ids beyondPASSIVE_MAX_SLOTS = 100wrap modulo 100 (an install that's churned through many VPs over time still produces a valid in-range slice). A same-slot collision (vp_id 101 lands on the same slice as vp_id 1) falls back to the per-session 10-attempt random retry that pre-#1646 code already had — same recovery, no regression. Compose default narrowed:docker-compose.ymlnow exposes50000-50029:50000-50029by default (covers 3 VPs out of the box) instead of the 1001-port range. The comment explains how to widen for more VPs (50000-500N9forN = vp_count - 1) and that proxy-mode VPs still need50000-50100:50000-50100because proxy mode forwards the real printer's full range — that codepath uses a separateTCPProxy.FTP_DATA_PORT_MIN/MAXand isn't sliced (the real printer owns that range, not Bambuddy). Doc corrections in the same drop: the previous warning over-stateduserland-proxy: falseas "confirmed by the reporter" — TheFou had flagged it as theoretical, not tested; the new comment doesn't push it as a recommendation at all (it's a global daemon flag, too blunt for a per-container problem). The new comment also explicitly names Linux multi-service hosts (NAS, dedicated Docker VMs, Unraid, Synology DSM) as a primary bridge-mode audience instead of leaving the warning under a "macOS/Windows" framing that TheFou pointed out missed his use case. Acknowledges that host-mode default is a deliberate trade-off for SSDP discovery, not a security-blind default. Tests: 10 new intest_vp_ftp_port_slicing.py—compute_passive_port_slicepins: vp_id=1 starts at base, consecutive vp_ids get adjacent non-overlapping slices, no two distinct vp_ids within MAX_SLOTS share a port (exhaustive across all 100 slots), wraps modulo MAX_SLOTS, top slot stays within the documented pool, non-positive vp_ids clamp to slot 0 (defensive — never produce a negative port that would crashasyncio.start_server). TwoVirtualPrinterFTPServerinstance tests pin: two instances constructed with different slices stay independent (regression guard against re-introducing class-level state), default-arg construction yields a valid one-slice window. Existing proxy-mode test attest_virtual_printer.py:2269(101 ports for_ftp_data_proxies) stays green — that path is unchanged. Full 130-test VP suite green. - Print Log "User" column now shows the user for prints started from the Queue (#1670, reported by JmanB52D) — Reporter on a P2S with auth enabled, Virtual Printer in Queue mode and Auto-dispatch off: a user uploads a
.3mfto the VP (FTP, anonymous), then logs into Bambuddy and clicks ▶ on the staged queue item to start it; the print finishes and the PrintLogEntry's User column is blank. Same setup with the VP in Archive (slicer-initiated) mode correctly attributes the user. Root cause: two-link gap on the Queue→manual-start dispatch path. (a)POST /queue/{id}/start(print_queue.py:1039) auth-protected, but the route's user dep was bound to_and discarded — the clicker was never recorded. (b)PrintScheduler._start_print(print_scheduler.py:1886) dispatches the queue item directly and never callsprinter_manager.set_current_print_user(...). The print-complete callback (main.py:3513) reads_print_user_info = printer_manager.get_current_print_user(printer_id)— which is only ever populated bybackground_dispatch.py:747/943(the Archive→Print and Library→Print flows). Queue dispatch had no equivalent hop, so_print_user_infowas alwaysNoneand the PrintLogEntry'screated_by_usernamelandedNULL. Fix (two-sided): (1)print_queue.py /startnow binds the auth dep touser: User | Noneand writesitem.created_by_id = user.idwhenuser is not None AND item.created_by_id is None— credits the clicker on VP-uploaded (unattributed) items without overwriting existing attribution from UI-added queue items (matches the standard "first claim wins" ownership rule inauth.py::require_ownership_permission). (2)print_scheduler.pygains a small_propagate_owner_to_printer_managerhelper, called from_start_printimmediately afterregister_expected_print: whenitem.created_by_idresolves to a real User row, it forwards(printer_id, owner.id, owner.username)intoprinter_manager.set_current_print_user. No-ops cleanly when the item has no owner (auto-dispatched VP items intrinsically) or when the user row is missing (e.g. user deleted between queue-add and dispatch — the print log row falls back to un-credited rather than crashing the dispatch). Tests: 6 new intest_queue_start_user_attribution.py— three route tests pin (a) authenticated/startwritescreated_by_idon an unattributed item, (b) an existing owner is preserved when a different user clicks/start, (c) auth-disabled leavescreated_by_id=NULL(no synthetic placeholder user invented); three helper tests pin (d) the propagation forwards the resolved username intoset_current_print_user, (e) aNoneowner is silently skipped, (f) a missing User row is silently skipped instead of raising. Full 63-testtest_print_queue_api.pysuite stays green. Backend ruff clean. - AMS drying popover's "Start Drying" button is no longer hidden behind iOS Safari's bottom URL bar on iPhone (#1669, reported via in-app bug report, iPhone 17 Safari) — Reporter could see the temperature / duration sliders and the "Rotate spool during drying" checkbox but couldn't reach the orange Start Drying button at the bottom of the popover — only a thin sliver of it was visible just above Safari's URL bar. Root cause: the popover sizes its
maxHeightagainst CSS100vh(PrintersPage.tsx:5443) and positions itself usingwindow.innerHeight(viacomputePopoverPosition,popoverPosition.ts:53). On iOS Safari both of those report the layout viewport — the full screen ignoring the bottom URL/toolbar overlay — not the visual viewport. The popover therefore extends behind Safari's bottom toolbar and the footer button gets clipped. Earlier iterations of the same surface (#1447 popover-off-bottom, #1458 footer-scroll-reachability) fixed desktop / normal-viewport cases but assumed100vhmatched the visible viewport. Fix: two-line change. (a)frontend/src/pages/PrintersPage.tsx:5443switchesmaxHeight: calc(100vh - …)→calc(100dvh - …)so the dynamic viewport units shrink with iOS toolbars. (b)frontend/src/utils/popoverPosition.ts:53defaultsviewportHeightfromwindow.visualViewport?.height ?? window.innerHeightso the flip-above decision also uses the actually-visible area; the existing optional override still wins (tests keep their explicit viewport values). Result: when the iOS toolbar is up, either the popover flips above the trigger earlier (visualViewport too short for below-placement), or the body scrolls within a capped maxHeight and theshrink-0footer stays pinned to the visible bottom — the Start Drying button is reachable in both cases. Tests: 3 new inpopoverPosition.test.ts::computePopoverPosition (#1669)— flip-above triggers when visualViewport.height (700) is shorter than innerHeight (800) and the trigger position would only overflow under the visual viewport; falls back to innerHeight when visualViewport is unavailable (older WebViews / jsdom); an explicitviewportHeightoverride still wins over a configured visualViewport.height (test-injection contract). 8 pre-existing tests stay green. dvh / svh browser support — Safari 15.4+, Chrome 108+, Firefox 101+ — comfortably covers iPhone 17 Safari and every supported desktop browser; no behavioural change on non-iOS. - Print queue
require_previous_successno longer cascades indefinitely after a user-cancelled print (#1667, fully root-caused by 599w6c26tv-droid) — Reporter on an A1 saw a single user-cancelled print block 18 downstream queue items over 3 days, all markedskippedwithPrevious print failed or was aborted. They captured the override log line proving Bambuddy correctly detects the cancellation (Overriding status 'failed' -> 'cancelled' for printer 1 (print was stopped from queue by user)) but the scheduler's gate ignored the override; they dumped the affected DB rows confirming the cascade pattern; and they reproduced from clean state in one cycle. Two distinct bugs in one function (PrintScheduler._check_previous_successinservices/print_scheduler.py): (a) The lookback query.in_(["completed", "failed", "skipped", "aborted"])excludedcancelled, so a user cancellation was never found as the most-recent predecessor — the query walked past it to whatever real outcome existed before. (b) The same lookback INCLUDEDskipped, so once one item got skipped (under any reason — bug-cascaded or genuinely failure-gated) it became the next item's "failed predecessor" and the cascade compounded. Fix: swap the lookback list to["completed", "failed", "cancelled", "aborted"]and broaden the success check toprev_item.status in ("completed", "cancelled"). A user cancellation is a deliberate action — treating it as neutral matches the user's intent ("I'm done with that one, move on");skippedis excluded so the query always walks back to the most recent REAL print attempt andfailed/abortedstill gate as before. Conservative recovery migration: a one-shot pass incore/database.py::run_migrationsresets only the skipped items whose immediate real predecessor (bycompleted_atdesc, excluding the skipped-cascade itself) wascancelled— same fingerprint as the bug, narrow enough not to disturb skipped items whose true predecessor was a realfailed/abortedprint. Items match onstatus='skipped' AND error_message='Previous print failed or was aborted'and the predecessor check via correlated subquery; logged per-row at INFO so operators can audit the count after upgrade. Portable across SQLite and Postgres. Idempotent (post-reset rows no longer match). Tests: 10 new behaviour tests intest_check_previous_success.pypin every status/cascade combination — bug A (cancelled → True), bug B (skipped walked past), the reporter's exact failed→cancelled→skipped→skipped→pending cascade, regression guards on real failed / aborted still gating, edge cases (no-predecessor, only-skipped history, completed-then-failed). 7 new tests intest_cancellation_cascade_recovery_migration.pypin the migration — skipped-after-cancelled resets, skipped-after-failed stays, skipped-after-aborted stays, different-error-message untouched, reporter's multi-item cascade resets all, idempotent on re-run, per-printer isolation. All green; full scheduler + migration test suite stays green. - Firmware-update check no longer 403s against Bambu Lab's Cloudflare-gated download page (#1666, reported by arekm, with the working bypass demonstrated) — Reporter on a fresh install hit
Could not reach Bambu Lab's firmware download page...when checking firmware for an A1 Mini, and surfaced the diagnostic:curl -H 'User-Agent: Bambuddy/1.0' https://bambulab.com/en/support/firmware-download/allreturnsHTTP 403 cf-mitigated=challenge— Cloudflare upped the bot-protection onbambulab.comto a JA3 / TLS-fingerprint challenge. Plain Python TLS handshakes (httpx, requests, urllib) don't match Chrome's ClientHello bytes, so CF rejects before the request reaches the app layer. TheAccept/Accept-Languageheader workaround we shipped for #1350 was below-HTTP and no longer enough. Existing users with abuild_id.jsonon disk from a previous successful fetch kept working until Bambu rebuilt the page (every few weeks); fresh installs and wiped data dirs hit the wall immediately — exactly the reporter's path. Fix: usecurl_cffifor the twobambulab.comfetches only. New dependency added torequirements.txt;firmware_check.pylazy-initialises acurl_cffi.requests.AsyncSession(impersonate="chrome", ...)for thebambulab.comcalls (the index page that carries the Next.jsbuildId, and the per-model_next/data/{buildId}/.../{api_key}.jsonendpoint). Smoke-tested end-to-end against the live page: returns 200 OK + validbuildId, vs the reporter's 403. Compliance framing matters here: per the Bambu-compliance email from 2026-05-12, Bambuddy committed to "no falsified client identity."curl_cffi's Chrome impersonation only governs TLS handshake bytes — the HTTP-layer User-Agent is overridden back toBambuddy/1.0 (+https://github.com/maziggy/bambuddy)via the session'sheaders=parameter. Defensible read: TLS fingerprint matches Chrome (necessary because Python's TLS is the signal CF gates on), but every application-layer identity remains honestly Bambuddy. A new test (test_bambulab_curl_cffi_session_keeps_honest_user_agent) pins this — a future refactor that drops theheaders=override would silently revert to curl_cffi's Chrome-default UA and break the compliance commitment; the test fails on any non-Bambuddy UA in the session. Soft dependency: ifcurl_cffifails to import (rare platforms, alpine without wheels, etc.), the service logs a one-time warning at startup and falls back to httpx; wiki-based version detection continues to work for the badge, only the in-app firmware download URL stops resolving. New testtest_bambulab_get_falls_back_to_httpx_when_curl_cffi_missingpins the fallback path. The wiki path (wiki.bambulab.com) and the CDN download path (public-cdn.bblmw.com) stay on httpx — neither sits behind the same JA3 gate. Three existing tests (test_build_id_is_persisted_to_disk,test_build_id_falls_back_to_disk_on_403,test_download_page_unreachable_flag_set_on_403_json,test_download_page_retries_once_when_buildid_stale) updated to mock_bambulab_getinstead of the raw httpx client — a tighter mock target that's stable across the curl_cffi / httpx switch.
Changed
- Slicer sidecar now ships as pre-built images on GHCR + Docker Hub — install works on QNAP / Synology / Container Station (#1657, reported by d3nn3s08) — Reporter on QNAP QTS 5.2.9 hit three install failures in sequence: the official
slicer-api/docker-compose.ymlusedbuild: { context: https://github.com/maziggy/orca-slicer-api.git#bambuddy/profile-resolver }, which requiresgitin the Docker BuildKit worker — Container Station and Synology DSM don't ship git there, so the build fails immediately withexec: "git": executable file not found. Manual ZIP-as-local-context workaround tripped a QNAP filesystem quirk in the systemd post-install (Failed to copy permissions from /etc/group). Fallback toghcr.io/afkfelix/orca-slicer-api:latest-orca2.3.0ran but couldn't slice — that image lacks thebambuddy/profile-resolverpatches (theinherits:chain resolver, thefrom: "User"→"system"rewrite, the#clone-prefix strip, and the sentinel-value strip), so/profiles/bundledreturned 400 and/slicereturnedInvalid parameter value(s) included in the 3mf file. The fix removes the build-from-source requirement entirely. Both sidecar images are now built locally on Martin's box and pushed to two registries (ghcr.io/maziggy/orca-slicer-api,docker.io/maziggy/orca-slicer-api, and the same two forbambu-studio-api) via a newdocker-publish-sidecars.shhelper in theorca-slicer-apirepo; the stable Bambuddy publish script auto-invokes it after each release, and the beta script too. Daily-beta opts in only via--include-sidecars(slicer rebuilds are expensive). The helper has hard safety guards: aborts unless the orca-slicer-api repo is onbambuddy/profile-resolverAND the working tree is clean, and never executesgit checkout/pull/fetch/resetitself.slicer-api/docker-compose.ymlswitches frombuild:toimage: ghcr.io/maziggy/orca-slicer-api:${SIDECAR_TAG:-latest}. NewSIDECAR_TAGenv var in.env.exampledefaults tolatest; setSIDECAR_TAG=bambuddy-X.Y.Zto pin to the sidecar image that shipped with a specific Bambuddy release. Scope limitation: both images arelinux/amd64only. The OrcaSlicer multi-arch path stays on hold pending an upstream extraction fix — the kldzj/orca-slicer-arm64 AppImage's--appimage-extractsilently fails under QEMU build emulation; the Dockerfile's;-chained RUN block masked the failure until the finalCOPY squashfs-roottripped. ARM64 hosts (Pi 4/5, Apple Silicon Linux) should run the sidecar on a separate x86_64 box and point Bambuddy at it via the Sidecar URL field — the sidecar doesn't need to live next to Bambuddy. Docs aligned:slicer-api/README.mdandwiki/features/slicer-api.mdrewrote the Quick start, Updating, and Sidecar source sections —docker compose up -dnow pulls instead of building, anddocker compose pull && docker compose up -dis the new update path (no--no-cache --pulldance because Compose only ever seesimage:references). The build-from-source path stays documented as an advanced option under "Building from source (advanced)" for forks / dev work.
Changed
- VP access code is now auto-derived from the target printer in non-proxy modes (Discord report) — A user on Discord set up a Queue-mode VP with a different access code than the real target printer and couldn't get the slicer to connect, even after the cert-trust path was sorted. Root cause: the live target-printer mirror that landed earlier in the 0.2.5 cycle forwards the slicer's MQTT/RTSPS auth bytes through to the real printer — the slicer holds one code in its profile (the one it bound the VP with), and that code has to pass two checks (VP listener, then real printer). If the codes diverge the bridge silently fails at the second hop and the slicer abandons the connection (e.g. opens 8883, FINs before sending a ClientHello). The wiki did document a code-match requirement but framed it as a camera-only concern (
MQTT and FTP work either way; only the camera path needs the match) — wrong, all bridged protocols inherit. The fix removes the foot-gun rather than re-document it. When a target printer is selected on a non-proxy VP (Archive / Review / Queue), the access-code field in the VP card switches to a read-only display showing the target's code with an Eye-toggle reveal, and the backend auto-inherits the value on everycreate/update(any explicitaccess_codesubmitted alongside a target is silently overridden — belt-and-braces for non-UI clients). When no target is set, the field stays editable as before. The sameinheritsAccessCodeFromTargetpredicate gates a small "Inherited from target" badge in place of the existingisSet/notSetstatus pill. Changing the target after the slicer has already bound triggers an info toast ("Access code now matches the new target — re-add this device in your slicer") because the slicer's stored code is now stale. One-shot startup migration incore/database.pycorrects any pre-existing mismatched VPs on first boot after the upgrade: SELECTs the diverged rows for an INFO log per VP (VP 'Workshop Queue' (id=3) access code synced from target printer 'X1C #2'— audit trail for anyone digging through logs), then UPDATEs via correlated subquery (idempotent — the WHERE clause excludes already-synced rows, so re-running is a no-op; portable across SQLite and Postgres). No user-facing banner because there's no action for the user to take — the fix is done, and a previously-stuck bridge now works. Wiki:features/virtual-printer.mdline 1189 flipped from the wrong MQTT/FTP-work-either-way claim to "the bridge forwards slicer auth bytes through; Bambuddy auto-derives so the codes can't diverge", the line-84 tip's "for camera" framing replaced with the broader rule, and the port-table row for RTSP:322annotated with "transparent passthrough to the real printer's:322, same end-to-end TLS as proxy mode" so the dedicated-bind-IP-vs-passthrough-to-printer apparent contradiction reads as one consistent model. i18n: 5 new keys (accessCode.inheritedFromTarget,accessCode.derivedFromTargetHint,accessCode.reveal,accessCode.hide,toast.targetCodeChangedRebind) translated in all 11 locales (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW), no English fallbacks per the project's hard rule.
Fixed
- Background asyncio tasks no longer get garbage-collected mid-flight (#1648 follow-up) — Support-bundle review under #1648 surfaced 94
Task was destroyed but it is pending!warnings in 8 days of v0.2.4.5. Root cause: asyncio holds only a weak reference to the result ofcreate_task— any "fire and forget" call site that doesn't store the returned task lets the event loop GC the task before it finishes. The warning gives no traceback, so the originating exception (if any) vanishes silently into a support bundle that looks scary but isn't actionable. Fix: newbackend/app/core/tasks.py::spawn_background_task(coro, *, name=None)helper that stores a strong reference in a module-level set, attaches a done-callback that auto-removes on completion AND surfaces any uncaught exception via the logger with the originating traceback, and accepts aname=argument so a leak source is traceable in/tracebacksand the log line. Migration: the 16 truly-orphanasyncio.create_task(...)call sites — acrossmain.py(8),printers.py,print_queue.py,firmware_update.py,archive.py,print_scheduler.py,library.py,smart_plugs.py,discovery.py,smart_plug_manager.py(3), andbackground_dispatch.py(2 lambda-wrapped) — switched tospawn_background_task. Othercreate_tasksites already kept strong refs viaself._tasks.append(...),self._x_task = ..., or localawait/gatherand stay unchanged. Tests: 5 unit cases intest_tasks.pypin the contract — strong-ref retention through completion, set-shrinkage after done, uncaught exception logged at WARNING withexc_info, cancellation does not log (a shutting-down service is not an error), and named tasks propagatename=. Net result: support bundles stop showing the opaque GC warnings, and any silent fire-and-forget exception now reaches the logger with a traceback attached. Severity reclassification of unrelated noise (the 791 "Failed to get cloud preset 400" spam, thebambu_cloud.Login failedmis-ERRORs, etc.) is a separate follow-up. - Home-page filament assign no longer leaves the slicer unaware of PFCN cloud presets (#1648, reported by ferch-G) — Reporter on an H2D with a Polymaker spool noticed that assigning the spool from the Dashboard left the slicer's filament dropdown showing "unknown" / generic, but clicking Configure right after made the slicer recognize it correctly — "Configure" felt like a mandatory follow-up step rather than a refinement. Root cause: PFCN-prefix cloud preset IDs were never handled. Bambu's cloud uses three preset-ID shapes:
GFS…(official Bambu),PFUS…(cloud user-created), andPFCN…(cloud shared / partner-uploaded — e.g. Polymaker's "(Custom)" Bambu Lab H2D variants like the reporter'sPFCN80e80c1f79db85).apply_spool_to_slot_via_mqttonly routedGFSandPFUSthrough the cloud-detail lookup that extracts the realfilament_id. PFCN slipped past the cloud-lookup branch, fell into the local-presetint()parse path, raised ValueError, dropped intonormalize_slicer_filamentwhich returns anyP-prefix unchanged, and the raw PFCN landed intray_info_idx— which the printer's calibration table can't index, so the slicer rendered "unknown". The Configure modal rescued each assign because it does its owngetCloudSettingDetaillookup and writes the resolvedfilament_id. Fix: extend the cloud-detail-lookup branch (inventory.py:129) and the discard safety net (inventory.py:223) to includePFCNalongsideGFS/PFUS. After the fix, the same three paths work: cloud-authenticated → realfilament_idfromdetail["filament_id"]ships astray_info_idx(Polymaker PLA Matte resolves toGFL05); cloud unavailable → raw PFCN discarded, slot reuses an existing valid P-prefix preset if material matches; nothing else available → falls through to the spool's generic material id (PLA → GFL99). Source comment now lists all three cloud-ID shapes so the next time Bambu invents a new prefix (PFXX, PFYY, …) the maintainer doesn't have to re-derive the structure from a bug report. Tests: 3 new integration cases intest_inventory_assign.py::TestAssignSpoolPfcnCloudPreset— falls back to generic when cloud unavailable (and pins the no-PFCN-leak invariant), reuses an existing slot's valid P-prefix preset when material matches, and the happy-path cloud lookup that produces a resolvedfilament_idwhile preserving the original PFCN assetting_id. Existing 28 assign-flow tests stay green. - Bambu cloud A1 Mini filament / process profiles no longer hidden in AMS slot picker (#1649, root-caused by technopaw) — Reporter on an A1 Mini observed that the AMS slot Configure dropdown showed no Bambu / Generic filament profiles; only user-authored profiles surfaced. Mirror in the Profiles tab: filtering by "A1 Mini" left only A1 (non-mini) results. Root cause: Bambu rolled out a profile rename mid-2026. The
BBL <code>suffix on cloud profiles shifted from the long display form to a terse model code —Bambu PLA Basic BBL A1 Mini ...is nowBambu PLA Basic BBL A1M ...across 106 cloud profiles. User-authored profiles still use the long form (which is why the reporter's custom A1 Mini profile worked, and Bambu PLA Basic happened to render via thelocalPresetalways-shown path). Bambuddy's filter compared the extracted token verbatim against the display name ("A1M".toUpperCase() === "A1 MINI"is false), so the rename silently stripped every newly-renamed profile from the picker. Fix: centralized alias-aware match infrontend/src/utils/slicerPrinterMatch.ts. NewPRINTER_MODEL_SUFFIX_ALIASEStable holds the bidirectionalA1 Mini⇄A1Mmapping (uppercase-normalised, narrow on purpose — wide-net aliasing likeX1⇄X1Cwould silently group truly distinct printers); exportedmatchesPrinterModelSuffix(presetSuffix, printerModel)helper does the case-insensitive compare with alias fallback. Both consumer sites switched to the helper:ConfigureAmsSlotModal.tsx:586,607(the AMS slot picker, hit directly + reached from SpoolBuddy's AMS page viamapModelCode(printer?.model)), andslicerPrinterMatch.ts:classifyByBambuName(the SliceModal Process / Filament compatibility check). BackendPRINTER_MODEL_MAPalso gains aBambu Lab A1M→A1 Minientry so server-side 3MF printer-model normalization stays consistent if a future 3MF embeds the short form. The structure stays open: when Bambu introduces the next rename, it's a single new row in the alias table —/api/v1/cloud/settingsis the place to grep, called out in the source comment. Tests: 7 new unit cases inslicerPrinterMatch.test.tspin the alias helper (canonical, case-insensitive both directions, A1M ↔ A1 Mini in both orientations, A1M does NOT collapse to A1, A1 does NOT collapse to A1 Mini, unrelated models reject) plus 3 integration cases inpresetCompatibility(cloud filamentBBL A1Mmatches A1 Mini, cloud processBBL A1Mmatches A1 Mini,BBL A1Mdoes NOT match A1). 2 new component-level cases inConfigureAmsSlotModal.test.tsx:BBL A1Mcloud preset surfaces when picker is for A1 Mini (withBBL A1correctly filtered out), andBBL X1Cstays filtered out when picker is for A1 Mini (sanity check against accidental widening). All existing 2062 vitest cases stay green. - VP Queue / Archive / Review: Bambu Studio 2.7.x stayed stuck at "Downloading" after Send (#1658, reported by IndividualGhost1905) — Reporter on Bambu Studio 2.7.1.57 + X1C reported that sending a model to a Queue-mode VP (with Auto-Dispatch off) left the slicer's send modal stuck at "Downloading" forever; clicking Delete on the queued item didn't release it, and even Auto-Dispatch ON + a successful real print didn't release it. Only toggling the VP off/on cleared the slicer. The deleted-from-queue framing is a red herring — the slicer was stuck before deletion, the user just noticed it most when they deleted. Root cause: the #1280 fix assumed the wrong event order. The original assumption was MQTT
project_file→ FTP upload → setgcode_state=FINISH, and the slicer's "Downloading" UI releases on FINISH. Bambu Studio 2.7.x flipped the Send sequence to FTPverify_job→ FTP.3mf→ MQTTproject_file, so on_file_received'sset_gcode_state("FINISH", …)fires first, then the synthetic_send_print_responseack runs and overwrites_gcode_stateback to"PREPARE". From that point the 1 Hz cached-as-base push stream carries PREPARE forever, the slicer waits for the FINISH transition it'll never see, and the modal sits stuck. Auto-Dispatch ON is the same bug: the real printer's gcode_state goes PREPARE → RUNNING → FINISH on its bridge, but_send_status_reportoverrides the cached push'sgcode_statewith the local_gcode_state(still PREPARE), so the real state changes never reach the slicer. The fix re-firesset_gcode_state("FINISH", filename, prepare_percent="100")fromon_print_command1.5 s after the synthetic ack, for every non-proxy mode (queue / archive / review). The 1.5 s window is long enough for the slicer's modal to see at least one PREPARE push on the 1 Hz cycle (so the transition reads as PREPARE → FINISH, matching what the slicer expects) and short enough that the modal feels responsive. Proxy mode is exempt — there the real printer drives the bridge state and a synthetic FINISH would clobber a real PREPARE/RUNNING transition. The scheduler cancels any in-flight timer when a new project_file lands so a slicer that retries doesn't end with two competing FINISH timers. Tests: 6 new cases intest_virtual_printer.py— schedules on archive (and by extension queue/review), proxy mode does NOT schedule, no-MQTT skip is silent, secondproject_filecancels the first timer, delayed run sets the expected(state, filename, prepare_percent)triple, empty filename does not schedule. - Finish photo no longer shows the bed already dropped (#1397, reported by rtadams89, Jeff-GebhartCA, MA2ZAK) — Bambu's end-gcode lowers the build plate as soon as the print completes. Bambuddy's existing finish-photo path captured a fresh camera frame at
gcode_state=FINISH, by which time the bed was already at the bottom of the chamber — the photo showed the top of the print well below the camera's natural framing, badly framed and sometimes invisible. Earlier capture attempts (atlayer_num >= total_layer_numwhile still RUNNING) hit motion-blur because the toolhead was still parking; capturing through the window kept the wrong frame because the latest was always ~2s before FINISH, mid-bed-drop. The fix sources the photo from a brief Bambu timelapse Bambuddy records on every dispatched print instead. Firmware stops timelapse recording AFTER the toolhead parks but BEFORE the bed-drop end-gcode runs, so the last frame frames the finished print correctly — verified on N=2 H2C prints by extracting the last frame of two real timelapses (spoolbuddy_v2.1andcase_SpoolBuddy); both showed the print clearly with the toolhead parked off-frame upper-left and the bed at print height, no motion blur. The post-park-pre-drop window is at least ~2 seconds wide on both, so-sseof -1.0(seek to last second, skip the literal last frame) is safe against any encoder tail artifact. Implementation: force-on at dispatch + cleanup after extraction.BackgroundDispatchService._resolve_effective_timelapse(db, archive, job)reads thecapture_finish_photosetting before eachstart_printcall (reprint + library-file flows both wired) and, when the user did NOT opt in to timelapse for this print, overridestimelapse=Trueon the MQTT command + marks the newPrintArchive.bambuddy_forced_timelapsecolumn True. User-opted-in timelapses pass through unchanged (no override needed). Migration adds the column branched onis_sqlite()for the boolean default (DEFAULT 0on SQLite,DEFAULT FALSEon Postgres — PG rejectsDEFAULT 0for BOOLEAN). New module-levelextract_video_last_frame(video_path, output_path)inservices/camera.pyruns a singleffmpeg -sseof -1.0 -i <video> -frames:v 1 -q:v 2 -update 1 <out>subprocess — no full transcode, ~150ms wall time on the dev box. Bounded 15s subprocess timeout that kills the child on hang. Returns False (never raises) on missing ffmpeg / missing video / non-zero exit / timeout._capture_finish_photo_from_timelapse(archive_id, archive_dir)pollsarchive.timelapse_pathevery 3s for up to 60s —_scan_for_timelapse_with_retriesruns in parallel and writes that field when the FTP download finishes. When it lands and the file exists on disk, extract the last frame asfinish_<timestamp>_<hex>.jpg._background_finish_photonow tries the timelapse path first whentimelapse_was_active=Trueand no external camera is configured; the existing external-camera / buffered-live-frame / fresh-RTSP-capture chain stays in place as the fallback. Post-extraction cleanup: whenarchive.bambuddy_forced_timelapseis True,_cleanup_forced_timelapse(archive_id, printer_id)runs after the extractor (regardless of success — the user never asked for a timelapse file and we shouldn't leave debris even if ffmpeg failed): deletes the locally-attached file, clearsarchive.timelapse_path, then walks the four scanner directories (/timelapse,/timelapse/video,/record,/recording) trying FTP DELE against the original filename. Best-effort, never raises — a printer that's offline at cleanup time means one orphaned file on the SD card, not a broken Bambuddy flow. Notification timing: the photo-task wait_for budget bumps from 45s to 75s when a timelapse was active so the notification carries the correct bed-up photo instead of falling through to the live-cam grab on slow Wi-Fi links; ~30s of added notification latency at worst is the honest tradeoff. Scope limitation, documented in the camera wiki: only covers prints dispatched THROUGH Bambuddy (queue, reprint, print-now from File Manager). Prints started directly on the printer's touchscreen, via the Bambu Handy app, or via Bambu Studio's "Send" function bypassbackground_dispatch.py, so the force-on doesn't fire and the live-cam fallback (bed-down) still applies for those. Can add the mid-printM981 S1 P20000MQTT toggle inon_print_startlater if anyone reports it for non-dispatched prints. External-camera users are unaffected throughout: their flow ignores the printer timelapse since external cams have their own framing and don't see one anyway. Verified on H2C only (core-XY); needs field verification on bed-slingers (A1, P1S) where the bed-drop kinematic geometry differs. Bed-slinger users invited to the test build to confirm. Setting description rewritten across all 11 locales to drop the "best quality when timelapse enabled" caveat (since Bambuddy now forces it) and call out the "kept-if-you-wanted-it, deleted-otherwise" behaviour. Tests: 6 intest_extract_video_last_frame.pycover the real ffmpeg happy path against a runtime-synthesised tiny MP4 (testsrc generator, ~3-5 KB, no committed binary fixture), missing source, empty source, ffmpeg-not-present (monkeypatched), nonzero-exit on garbage input, hung subprocess via patched-sleep-binary + tightened timeout. 4 intest_finish_photo_from_timelapse.pycover the polling helper with a patched-session fixture (no DB engine): timeout-without-landing, lands-and-extracts, lands-but-extraction-fails, file-materialises-mid-poll. Override + cleanup tests intest_dispatch_force_timelapse.pyandtest_cleanup_forced_timelapse.pypin: override fires only whencapture_finish_photoenabled AND user-timelapse off; user-opted-in timelapse passes through unchanged; cleanup deletes local + clearstimelapse_path+ DELEs remote when forced; cleanup skips when not forced. Round-2 fixes from field testing (Martin's H2D + X1C queue test): (a) the extractor's-sseof -1.0seek broke on small-print timelapses — Bambu records one frame per layer change, so a 16-layer cube produces a 0.625 s / 16-frame video and the 1-second seek-from-end went before the start of the file, ffmpeg silently returned frame 0 (empty bed at print start). Switched the extractor toffmpeg -i input.mp4 -update 1 -q:v 2 out.jpg— writes every decoded frame to the same output file (overwriting), so the file left on disk is the last frame regardless of duration. Verified on Martin's actual X1C-2 timelapse: produces the finished red cube with toolhead parked upper-left. Newtest_extracts_correctly_from_sub_second_videoregression test pins it against a 0.5 s synthetic MP4. (b) The dispatch-time override only wired intobackground_dispatch.py(Print Now / Reprint flows), but the print queue runs through a separate scheduler atprint_scheduler.py:_start_printwhich callsprinter_manager.start_printdirectly. Refactored the resolver to a module-levelresolve_effective_timelapse(db, archive, user_wanted_timelapse)function and wired it into the scheduler's call site too. Newtest_scheduler_force_timelapse_wiring.pywalks the scheduler's AST and asserts thestart_print(timelapse=...)kwarg referenceseffective_timelapse(notitem.timelapse) — guards against future refactors silently dropping the override on the queue path.
Added
- Add Printer: scan a custom subnet for printers behind a router on a different L3 segment (#1564, reported by MartinNYHC, root-caused by IndividualGhost1905) — Reporter on a flat LAN couldn't add a printer that lived in a different subnet (
Bambuddy 192.168.1.0/24↔printer 10.1.1.0/24). SSDP multicast (239.255.255.250:2021) doesn't traverse routers, so the existing "Discover Printers on Network" pass found nothing; Docker mode had a CIDR text input but only as a fallback when zero interface subnets were detected, and native mode had no subnet field at all. The discovery socket has always boundINADDR_ANYso this was never an interface-bind issue — only a routing-boundary one. The fix surfaces an always-visible subnet picker inAddPrinterModal: the detected interface subnets stay as the dropdown options, plus a new "Custom subnet..." sentinel reveals a CIDR text input the user can type any reachable subnet into (10.1.1.0/24, a VLAN, a Tailscale subnet route, etc.). When custom is picked, the discovery routes throughPOST /discovery/scanwith the typed CIDR instead ofPOST /discovery/start— SSDP would no-op against a foreign subnet anyway, so this is the only behaviour that can succeed. The Scan-button label and the scanning / no-printers-found messages all key off the(isDocker || useCustomSubnet)predicate so the wording stays "Scan Subnet…" / "Scanning subnet…" — the user sees one consistent verbal model whether they're on Docker or just picked Custom. Last custom CIDR is persisted tolocalStorageunderbambuddy.discovery.customSubnetand restored on next modal open, so a user who maintains a VLAN setup doesn't retype10.1.1.0/24every time. Backend changes: none.SubnetScanner.scan_subnet()already accepts any CIDR, already caps the scan at /22 (1024 hosts) with batch-50 concurrency, and the route/discovery/scanalready takes user-supplied input — the existing plumbing was complete. i18n: 3 new keys (customSubnetOption,customSubnetLabel,customSubnetNote) translated in all 10 non-English locales (de/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW), no English fallbacks per the project's hard rule. The note text spells out the routing-boundary requirement: "The FTP (990) and MQTT (8883) ports must be reachable across the routing boundary" — a user who can pick a subnet but whose firewall blocks 8883 will at least see why the scan came up empty. Tests: 3 new inPrintersPageDiscoveryCustomSubnet.test.tsx— picker renders on native installs (was Docker-gated before), picking Custom + entering a CIDR routes throughdiscoveryApi.startSubnetScannotstartDiscoveryand persists the choice vialocalStorage.setItem, picker default (the detected interface subnet) still triggers SSDP viastartDiscovery.AddPrinterModalexported fromPrintersPage.tsxso the tests can mount it directly without round-tripping through the full page (same shape asProjectModalfor the #1642 tests).
Fixed
- Project edit modal couldn't be scrolled, so Save / Cancel were unreachable on short screens (#1642, reported by klevin92) — Reporter on a Pi-class display (1508 × 831) couldn't mark a project as Completed because the edit modal's height exceeded the viewport and there was no way to scroll: outer wrapper was
fixed inset-0 flex items-center justify-center p-4(vertical-center) and the inner card had nomax-hand nooverflow. The top of the form went above the viewport and the bottom — including the Status dropdown the reporter was trying to use plus both action buttons — went below it. Workaround was a full page reload to drop the modal. Standard flex-modal-scroll fix:max-h-[calc(100vh-2rem)]+flex flex-colon the card (the2remaccounts for the outerp-4), aflex-1 overflow-y-auto min-h-0wrapper around the form fields, and the Cancel / Save buttons moved into aflex-shrink-0sibling with aborder-tseparator so they become a sticky footer that's always visible regardless of scroll position. The buttons stay inside the<form>sotype="submit"still works. 2 new vitest cases inProjectsPage.test.tsxpin the structural fix: the Save button is NOT a descendant of theoverflow-y-autoregion (otherwise it would scroll off again) and the modal card carries themax-h-[calc(100vh-2rem)]cap. Other modals in the codebase with the samefixed inset-0 flex items-center justify-center+max-w-mdshape almost certainly have the same latent bug — not refactored here, will tackle when reported.
Changed
- File Manager sidebar: "All Files" now scopes to your own uploaded files; new "External" entry holds the combined linked-folder view (#1621, reported by kcw96) — Reporter linked a NAS share that auto-imported hundreds of 3MFs, and from then on their handful of Bambuddy-uploaded files was lost in the "All Files" listing — no filter, no toggle, only per-folder clicks to escape the noise. Restored the pre-external semantics so long-time users get their muscle memory back: "All Files" lists managed-storage files only (
is_external=False), exactly what it meant before external folders existed. The combined "everything across every external mount" view moves to a new sibling sidebar entry, External, which only renders when at least one external folder is linked (zero-cost on installs that don't use the feature). Per-folder clicking is unchanged: clicking any folder in the tree — internal or external — still shows that folder's contents directly. Backend:/api/v1/library/filesgains two mutually-exclusive query flags,internal_onlyandexternal_only, filtering directly onLibraryFile.is_external. Both-flags-set is a 400 (catches frontend regressions immediately instead of silently picking one). Folder- or project-scoped requests bypass both flags because they already imply a single scope. Frontend: newtopLevelView: 'internal' | 'external'state onFileManagerPage, defaultinternal; the query passes the corresponding scope only whenselectedFolderId === null. Sidebar shows the "External" row gated onfolders.some(f => f.is_external); mobile selector dropdown carries a__top:internal/__top:externalsentinel so the same state can round-trip through<option value>. Empty-state copy distinguishes "no internal files yet" from "no external files" so a user staring at an empty External view doesn't think their NAS is broken. i18n: 3 new keys (allExternal,externalIsEmpty,externalEmptyDescription) translated in all 10 non-English locales. Tests: 3 new backend integration tests intest_library_api.py(internal-only with mixed root + folder + external file mix, external-only across two NAS mounts, mutually-exclusive 400) and 3 new frontend tests inFileManagerPage.test.tsx(External entry conditional onis_external, default internal-only query, External-click switches scope). Existing 48FileManagerPagetests + 11FileManagerExternalFoldertests stay green. Behaviour change for the small set of users who relied on the combined view as default: clicking "External" once gets the previous union behaviour (across-all-externals); clicking a specific external folder still shows just that mount, same as before.
Added
- Orca Cloud profile sync — end-to-end integration with the slicer + SpoolBuddy surfaces (OrcaSlicer/OrcaSlicer#14028 filed for upstream allowlist broadening) — Bambuddy now reads, lists, and slices with profiles from your Orca Cloud account alongside the existing Bambu Cloud integration. OrcaSlicer 2.4.0-alpha shipped its own cloud (Supabase-backed at
auth.orcaslicer.com/api.orcaslicer.com); this integrates with it using the in-source publishable client key, a standard PKCE handshake, and the/api/v1/sync/pullprofile-sync endpoint. Four sign-in providers: Google, Apple, GitHub (paste-flow PKCE) and email+password (direct grant — Orca's web sign-in offers it even though their desktop SDK refuses); UI defaults to password with the three OAuth options listed below. UX shape: the Cloud Profiles tab is now two — "Bambu Cloud" (existing, unchanged) and "Orca Cloud" (new); the paste flow's "page will fail to load — that's expected" instruction is rendered as a prominent amber callout so the connection-refused page isn't mistaken for a Bambuddy error. The Orca Cloud tab renders the same rich profile-browser layout as Bambu Cloud (search + 5 filter dropdowns + 3-column grouped grid + click-to-detail) via a parallelOrcaCloudProfilesViewcomponent. We chose paste-flow rather than a clean OAuth callback because Orca's Supabase project only honors localhost in itsredirect_toallowlist. Slicer integration: the unified-presets endpoint surfaces Orca Cloud as a 4th tier above Bambu Cloud > local > standard;_dedupe_by_nameand the SliceModal dropdowns both updated to walk all 4 tiers. The dedicated_fetch_orca_cloud_presetsextractsfilament_typeanddefault_filament_colourinline from each profile's content (cheap because/sync/pullreturns full content per profile — no rate-limit dance like Bambu Cloud's per-setting fetch), so multi-color pre-pick scoring works against Orca presets too. A separateCloudStatusBannerinstance shows Orca Cloud's auth status independently of Bambu's. AMS slot integration:ConfigureAmsSlotModalacceptsorca_cloudas a new preset source (prefixedorca_<UUID>to match the existinglocal_*/builtin_*convention), gracefully tolerating raw UUIDs from historical saves; Orca presets are treated like local imports fortray_info_idxderivation (no Bambu setting_id, generic filament-ID map by parsed material). Slot mapping persisted withpreset_source='orca_cloud'. SpoolBuddy integration:SpoolFormModalandSpoolBuddyWriteTagPagefetch Bambu + Orca filaments in parallel viaPromise.allSettledand concat;ConfigureAmsSlotModalopens fromSpoolBuddyAmsPage's Configure flow with Orca presets surfaced first. Storage: 8 new columns onusers(5 persistent + 3 transient PKCE state with 10-min TTL), dialect-branched DATETIME / TIMESTAMP, verified on SQLite and Postgres. Auth-disabled mode falls back to global Settings table. Refresh rotation: Supabase issues single-use refresh tokens; service refreshes just-in-time (<5min leeway) and persists the new pair BEFORE the downstream call so a mid-flight crash doesn't strand the user. Cloudflare:api.orcaslicer.comis behind a UA-only gate;Bambuddy/<version>clears it (no TLS-fingerprint games). Per the [[bambu-compliance-outreach]] posture we identify honestly. Preset resolver:PresetRef.sourceextended to'orca_cloud' | 'cloud' | 'local' | 'standard';_resolve_orca_cloudli
Changelog truncated — see the full CHANGELOG.md for the complete list.