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

pre-release4 hours ago

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_storage diagnostic check. The diagnostic catches the printer-side variant of "Store sent files on external storage" via home_flag bit 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 with extra_data.no_3mf_available=True (main.py:2770) — that's the signal this banner watches. New backend endpoint GET /archives/no-3mf-warning returns {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 via localStorage key archiveNo3MFWarningDismissed (matches the existing Layout.tsx update-banner pattern but persistent across sessions, since "you've been told" should outlive a browser restart). React-Query is enabled: !dismissed so 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) under archives.no3mfBanner translated 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.3mf on 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 MQTT home_flag bit 11 (Bambuddy already parses this into state.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 /cache directory 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_flag bit 11 stayed True). Fix: new external_storage check reads state.store_to_sdcard directly. 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 push home_flag). Localised fix-text points at install step 4 with both the printer-side and slicer-side variants spelled out; the skip text explicitly calls out the older-slicer limitation so users on that path know to verify manually. Slot in the check list sits between port_ftps and mqtt_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 records extra_data.no_3mf_available=True after 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_slicer setting 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: new open_in_slicer setting ('bambu_studio' | 'orcaslicer' | null) drives only the desktop "Open in Slicer" URI handoff; the in-app SliceModal + sidecar URL routing in library.py, archives.py, slicer_presets.py continue to use preferred_slicer exactly as before. Default is null — the frontend falls back to preferred_slicer so existing installs behave identically until a user changes it (no migration, no churn). Storage lives in the existing app_settings key/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_response matching the existing default_printer_id convention — 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 (5 openInSlicerWithToken call sites), MakerworldPage (the URI handoff branch when useSlicerApi=false), and ModelViewerModal (4 openInSlicer(...) call sites) all switched from reading settings?.preferred_slicer to settings?.open_in_slicer ?? settings?.preferred_slicer. MakerworldPage's "Slice in {{slicer}}" button label additionally branches on useSlicerApi: 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 updated settings.preferredSlicerDescription everywhere (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 in TestOpenInSlicerOverride pin 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 in utils/threemf_tools.py, alongside the existing extract_filament_usage_from_3mf shape — reads Metadata/slice_info.config, finds the <plate> with the matching index, returns its curr_bed_type. When plate_id is None it returns the first plate's value (matches the archive-level capture convention). PrintQueueItemResponse gains a bed_type: str | None field; _enrich_response populates it from archive.bed_type / library_file.file_metadata["bed_type"] as the file-level default, then overrides per-plate via the new helper when item.plate_id is set. This matters because archive.bed_type is captured at ingest as the FIRST plate's value only (see services/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 include bed_type in 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 existing getBedTypeInfo(bed_type) helper from utils/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-plate PlateSelector shows 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 + PlateMetadata types both get an optional bed_type field. No new i18n keys needed — getBedTypeInfo returns the canonical English plate name as the human label, matching the archive card's existing convention. Tests: 8 new unit cases in test_threemf_tools.py::TestExtractBedTypeFrom3mf pin 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 tags describe 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 from archive.failure_reason when the user edits the archive (see archives.py:1421 for 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 from PrintLogEntrySchema, 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.py GET endpoint now includes failure_reason (and archive_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) New PATCH /print-log/{entry_id} endpoint accepting {failure_reason, status}, gated on require_ownership_permission(ARCHIVES_UPDATE_ALL, ARCHIVES_UPDATE_OWN) — same ownership shape as the per-row delete that already shipped. Backend validates failure_reason against the same canonical vocabulary the Archive Edit modal uses (11 enumerated keys + empty-string-clears + the other catch-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-string failure_reason stores back as NULL so the column's nullable=True intent is preserved end-to-end. (3) FAILURE_REASON_KEYS constant moved to an export from EditArchiveModal.tsx so 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 on archives:update_own/archives:update_all. Click opens a compact two-field modal (status + failure reason dropdowns). Save invalidates both print-log and archives-stats query 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-archive PrintLogTable.tsx convention so the two views agree. i18n: 10 new keys (editEntryTitle, editEntryDescription, entryUpdated, entryUpdateFailed, archives.permission.noEdit, plus a 5-key statuses block) translated across all 11 locales — no English fallbacks per feedback_translate_dont_fallback. Tests: 8 new backend integration cases — GET surfaces failure_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) or archives:delete_all (any row), matching the archive-delete permission shape. Click → confirm modal → row is gone, and because /archives/stats aggregates over PrintLogEntry the 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: new DELETE /print-log/{entry_id} mirrors delete_archive's ownership flow via require_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: new deletePrintLogEntry API helper, per-row mutation that invalidates both print-log and archives-stats query 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 accidental delete(PrintLogEntry) without a where). 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 by EditArchiveModal'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 existing close_all_connections() step before the DB swap only disposes the SQLAlchemy engine's connection POOL — the asyncio tasks that USE the engine keep running. The print_scheduler.run() loop (30 s cadence) and smart_plug_manager._snapshot_loop() (30 s cadence) wake up after the dispose, call async_session(), lazily reopen a pool connection, and start a normal transaction that grabs RowExclusiveLock on print_queue / smart_plug_energy_snapshots. The restore's DROP TABLE IF EXISTS public.<tbl> CASCADE pass in _import_sqlite_to_postgres needs AccessExclusiveLock on every public table — AB/BA lock-order conflict, classic Postgres deadlock, restore transaction rolled back. The log confirms: 13:44:53,669 restore begins → 13:44:53,680 print_scheduler fires queue check → 13:44:55,607 smart_plug_manager fires snapshot → 13:44:55,607 deadlock detected. The existing code already paused virtual_printer_manager before restore for file-lock reasons; the other timer-based DB writers were missed. Fix — two layers. (1) Before close_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(). Then await 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: prepend SET LOCAL lock_timeout = '10s' to the begin-block before the DROP TABLE CASCADE pass, 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 fast lock_timeout error instead of producing a fresh deadlock or hanging the restore for 30+ seconds. SET LOCAL is 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.py and test_settings_api.py integration suites (53 tests) stay green on the edited handler; ruff clean; runtime smoke (from backend.app.services.X import Y + hasattr + iscoroutinefunction check) 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="", no slot_preset_mappings row), 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. matchingKProfiles in ConfigureAmsSlotModal.tsx:751 early-returned [] when selectedPresetInfo was null — and selectedPresetInfo resolves 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 when selectedPresetInfo is null and slotInfo.caliIdx > 0, find the active profile by slot_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; with caliIdx === 0 || null the function still returns [] (no unrelated profiles leak in). Tests: new vitest case surfaces the slot's active K-profile when no preset is resolvable (#1689 follow-up) exercises the path with trayType='', no savedPresetId, and caliIdx=6 against a K-profile fixture at slot_id=6 — asserts the dropdown surfaces it. Verified the test fails without the patch (stash → run filter → fail; pop → run → pass). The existing caliIdx === 0 guard 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-By on 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.tsx via isMatchingCalibration in spool-form/utils.ts) only matched K-profiles by parsing the profile name for material/brand/variant. Spools already store slicer_filament (the slicer preset's id) and K-profiles already carry filament_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 store slicer_filament as the cloud setting_id form ("GFSG98_09" — _09 is the variant suffix, the "S" infix marks it as a setting_id); K-profiles store filament_id as the bare form ("GFG98"). Plain === doesn't match; both need normalising first. This conversion already exists in the other direction at buildFilamentOptions (filament_id → "GFS" + filament_id.slice(2) for setting_id), so the inverse toFilamentId helper isn't speculative — it's just the matching reverse. Fix — one shared helper, two surfaces: new exports in frontend/src/components/spool-form/utils.tstoFilamentId(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 generic GFx99 ids (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) isMatchingCalibration accepts a new slicer_filament?: string formData field, tries id-match first (with generic exclusion), falls through to the existing name parse — PAProfileSection already passes the full formData so no caller edit needed. (2) ConfigureAmsSlotModal.selectedPresetInfo now also resolves a filamentId (via toFilamentId(cp.setting_id) for cloud presets; toFilamentId(builtinFilamentId) for builtin; empty for local/orca paths that fall through to name match); matchingKProfiles adds 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 (by slot_id === slotInfo.caliIdx, gated on activeIdx > 0 so 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 — SpoolBuddyWriteTagPage renders <PAProfileSection> (auto-fixed via isMatchingCalibration), SpoolBuddyAmsPage renders <ConfigureAmsSlotModal> (auto-fixed via matchingKProfiles). No kiosk-specific edits required; the shared helpers carry the fixes through. (SpoolBuddyCalibrationPage is scale calibration, unrelated; InventorySpoolInfoCard is 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 the toFilamentId round-trip in both directions (GFSG98_09 → GFG98 and back is identity-preserving for the cloud→K-profile flow), the generic GFx99 exclusion, 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 the caliIdx == 0 guard 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 (the GFSG98_09 ↔ GFG98 normalisation 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) and Co-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.user stays 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 in frontend/src/api/client.ts:154-167, the handler calls setAuthToken(null) to drop the token from sessionStorage / localStorage — but AuthContext.user is a React state value that was populated once at mount via checkAuthStatus()/auth/me, and setAuthToken(null) doesn't reach into AuthContext's React tree. ProtectedRoute (App.tsx:101) only redirects when user === 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 remounts AuthProvider, checkAuthStatus() finds no token, setUser(null) fires, the redirect runs — which is what the reporter ended up doing every 24 h. The 3 other setAuthToken(null) call sites all live inside AuthContext itself and pair with setUser(null) directly, so no cross-module signal was needed for them; the client.ts:165 site was the only one missing the React-tree notification. Fix (mirrors the reporter's fork patch deec96d): after setAuthToken(null) in client.ts, dispatch a window.dispatchEvent(new CustomEvent('auth:expired')) (guarded on typeof window !== 'undefined' for SSR / test safety). AuthContext's mount useEffect adds a window.addEventListener('auth:expired', handleAuthExpired) listener whose handler calls setUser(null) after a mountedRef.current guard, and removes the listener in the effect's cleanup so unmount → remount doesn't double-bind. ProtectedRoute then sees user === null on the next render and runs <Navigate to="/login" replace /> immediately, no manual refresh needed. What this intentionally does NOT change: generic 401 Authentication required responses (without a token-invalidating message) still don't clear the token or fire the event — they're treated as transient timing issues, exactly as client.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.ts gains "dispatches 'auth:expired' event on 401 with invalid token message" and "does not dispatch 'auth:expired' on 401 with generic auth error" (both use vi.fn() listeners on window to assert the event fires/doesn't fire). AuthContext.test.tsx gains a new auth: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 via setAuthToken('valid-token') (the canonical setter; writing to sessionStorage post-import wouldn't propagate to the module-level authToken variable initialised at import time), and "does not crash when the event fires after unmount" pins the mountedRef guard 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) in backend/app/utils/threemf_tools.py already supports filtering and the queue's pre-flight capacity check at api/routes/print_queue.py:254/:286 passes item.plate_id, but the two completion-time recorders did not: _track_from_3mf in services/usage_tracker.py:907 (internal Filament Inventory) and store_print_data in services/spoolman_tracking.py:223 (Spoolman mode) both called the extractor with no plate_id and summed every plate. Per feedback_inventory_modes_parity both modes had to ship in the same drop, AND per the verification pass after the initial implementation: the direct-Print path (api.reprintArchive / api.printLibraryFile with plate_id: selectedPlate in PrintModal/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) PrintSession gains a plate_id: int | None field; on_print_start queries PrintQueueItem for the printer's currently-printing row and records queue_item.plate_id onto the session — covers the queue path. (2) register_expected_print in main.py accepts a new plate_id parameter 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 and print_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.py injects _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 existing ams_mapping injection pattern. The dict drains on on_print_complete and on TTL eviction of the matching _expected_prints entry — same lifecycle as _print_ams_mappings. (3) _track_from_3mf accepts a new plate_id kwarg, threads it from session.plate_id, and passes it to extract_filament_usage_from_3mf. (4) store_print_data accepts a plate_id kwarg; the 3 call sites in main.py pass _get_start_plate_id(archive_id) (new helper, parallel to _get_start_ams_mapping); within store_print_data the caller value wins, falling back to queue_item.plate_id for the queue path. The PrintArchive's filament_used_grams stays 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_id returns 1 → registered as plate_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 across test_usage_tracker.py + test_spoolman_tracking.py + test_print_start_expected_promotion.py — plate_id propagation through _track_from_3mf; absence leaves it None; on_print_start captures queue_item.plate_id; on_print_start no-op when no queue item; Spoolman-mode plate-scoped extract; register_expected_print stores plate_id in _print_plate_ids; _get_start_plate_id reads it back; injection into session for direct-Print (no queue capture); guarded against overwriting an already-captured queue plate_id. The pre-existing test_prefers_explicit_ams_mapping_over_queue_mapping updated 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 a getEmptySlotKind helper 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 on emptyKind'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's AmsUnitCard was carrying the same bug and got the same fix (mirror of getEmptySlotKind, "?" vs "Empty" label, tooltip "Spool loaded — slot not configured"). i18n: new ams.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 existing ams.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 gains shows "?" for loaded-but-unconfigured slot (#1694) pinning both branches in one render (one slot with state: 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 set SO_KEEPALIVE on 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 adds test_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_connect pins setsockopt(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 to psutil.boot_time() within a sub-second). Defensive psutil.Error / OSError fallback to the old psutil.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 reads Process(1).create_time and that the response uses that timestamp (not boot_time), the other pins the fallback path via a real psutil.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_type was wrong and the printer received the wrong material code at dispatch. Root cause: backend/app/data/filament_fields.json (served by GET /cloud/fields/filament and consumed by ProfilesPage via getCloudFields) 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.py GENERIC_FILAMENT_IDS, spool-form/utils.ts MATERIALS, the Bambu filament-id catalog in cloud.py), so the gap was specifically in the editor's allowed-values JSON. Fix: expanded the filament_type select 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 picks filament_id, not filament_type. Tests: 15 unit cases in test_filament_fields_options.py pin 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.service shipped with ProtectHome=true, which makes /home/* invisible to the service namespace. When the user installed into /home/bambuddy/ (instead of the default /opt/bambuddy/), the ExecStart=/home/bambuddy/venv/bin/uvicorn path couldn't be resolved at exec time and the unit failed with status=203/EXEC: Unable to locate executable. The ReadWritePaths=$INSTALL_PATH directive doesn't reliably re-expose /home/* subpaths for executable resolution. Fix: install/install.sh now detects INSTALL_PATH == /home/* and emits ProtectHome=read-only for that case; the default /opt/bambuddy/ install keeps the stricter ProtectHome=true. The manual deploy/bambuddy.service template defaults to ProtectHome=read-only with a comment explaining when to tighten it to true. read-only keeps /home immutable to the service (no security regression — the service can read its venv but not write anywhere outside the ReadWritePaths allowlist).
  • 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-printers response — 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 accepts db; when vp.mode == VP_MODE_PROXY and vp.target_printer_id, it issues a single SELECT serial_number FROM printers WHERE id = vp.target_printer_id and substitutes the result into the response serial field. 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_dict call sites (list, create, get, update) updated to await with db. Tests: 3 new in TestVirtualPrinterSerialSurface — 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, includes weight_used, last_used, and the SpoolCreate fields storage_location / category / low_stock_threshold_pct so the round-trip preserves the per-spool location data from #1291. remaining is 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: explicit rgba wins, otherwise brand + color_name resolves against the Color Catalog (case-insensitive, single in-memory pass — no N+1); a catalog entry with material = NULL is treated as the project's "matches any material" convention so a generic match counts as exact rather than firing the cross-material warning. Validation reuses SpoolCreate so every constraint that already protects manual adds (weight_used >= 0, weight_used <= label_weight, low_stock_threshold_pct range, etc.) protects bulk imports too. Hardening: 5 MB upload cap with a structured csv_import_too_large 413 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 is None for 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: a duplicate_of_existing flag 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:3445 always wrote "nozzle_offset_cali": 2 (skip) into the MQTT project_file payload, 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 corresponding cali_idx=2 MQTT 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 of nozzle_offset_cali with a hard MQTT-layer gate on dual-nozzle. start_print() (bambu_mqtt.py:3300) gains a nozzle_offset_cali: bool = False kwarg 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 reuses is_dual_nozzle_model() and the runtime _is_dual_nozzle flag (set when device.extruder.info has ≥ 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 to 2 so firmware never tries to calibrate a head it doesn't have. The kwarg threads through printer_manager.start_print(), both background_dispatch call sites, and print_scheduler._start_print so every dispatch path — direct reprint, library file, queue-dispatched, watchdog-recover — respects the per-item setting. Persistence: print_queue.nozzle_offset_cali column (BOOLEAN DEFAULT TRUE, branched on is_sqlite() because Postgres rejects DEFAULT 1 for 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. New default_nozzle_offset_cali setting (default TRUE) plumbed through schemas/settings.py, the settings PUT allowlist, and the SettingsPage card — the row in Settings → Default Print Options only renders when printers.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 a showDualNozzleOptions prop and filters the option list; PrintModal/index.tsx computes it from selectedPrinters.some(p => p.nozzle_count === 2) in printer-mode or from a small inline DUAL_NOZZLE_MODELS set in model-mode (mirrors the backend DUAL_NOZZLE_MODELS frozenset: H2D, H2DPRO, H2C, X2D). The same gate flows through QueuePage bulk-edit — the new tri-state toggle only renders if any registered printer has nozzle_count === 2. Labels reuse the existing settings.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 in test_bambu_mqtt.py pin the four-quadrant gate: default value (P1S, no kwarg → 2), single-nozzle ignore (P1S, kwarg True → still 2 — the safety net), dual-nozzle honour (H2D, True1), dual-nozzle false (H2D Pro, False2 — the diamond-nozzle case). test_printer_manager.py updated for the new kwarg in assert_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 MQTT ams_filament_setting publish 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 with pending_config=true, and on_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 line Pre-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:153 always called showToast(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 on pending_config and showed a distinct "Slot will configure when you insert the spool" message; the printer-card modal was just never updated to match. Fix: AssignSpoolModal.tsx now reads newAssignment.pending_config and 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: new inventory.assignPendingInsert key in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), translations copied verbatim from the existing parallel spoolbuddy.modal.assignPendingInsert entries 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 in AssignSpoolModal.test.tsxshows the pending-insert toast when backend returns pending_config=true (#1680) pins the new branch (slot-was-empty case the reporter hit), and shows 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 showed aborted for 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 fresh BambuMQTTClient is constructed with PrinterState defaults — most importantly state.state = "unknown" and state.subtask_name = "". The MQTT _on_connect callback (bambu_mqtt.py:668-669) broadcasts on_state_change(self.state) immediately after the broker accepts the connection — BEFORE the _request_push_all round-trips with the printer's real status. on_printer_status_change (main.py:825) sees state.connected=True flip on the connected-edge, spawns reconcile_stale_active_prints for that printer. The reconcile walks every archive in status="printing", calls _is_active_archive_stale (main.py:3352) — which sees state.state="UNKNOWN" (skips the IDLE/FINISH/FAILED branch), then state.subtask_name="" (matches trigger 3, "printer subtask_name empty") and returns stale. A synthesised aborted PRINT 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_prints doesn'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_prints for 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 first push_status response 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_change now gates the reconcile spawn on state.state being a real value — state_known = bool(state.state) and state.state.upper() not in ("", "UNKNOWN") — so reconcile doesn't fire until the first push_status updates state.state to a real Bambu firmware value (RUNNING / IDLE / FINISH / PREPARE / SLICING / PAUSE / FAILED). When that real push arrives, on_printer_status_change fires again, the connected-edge flag is still False (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 reports IDLE on its first real push after reconnect, reconcile catches it the way it always did. (2) Belt-and-braces: _is_active_archive_stale now returns (False, "") when state.state is 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.py 26 cases (up from 21) — new parametrize test_pre_push_state_returns_not_stale_even_with_empty_subtask pins all five degenerate forms ("unknown", "UNKNOWN", "Unknown", "", None) and asserts none triggers stale even with empty subtask_id + empty subtask_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_file but 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 MQTT project_file was ACK'd, printer pushed gcode_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_start returned SUCCESS as soon as subtask_id advanced. The subtask_id-as-pickup-signal was added for H2D, which can sit at FINISH for ~50 s after accepting project_file before 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 at status='printing', the in-memory _expected_prints entry 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+) and bambu_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 to timeout, default 90 s, unchanged behaviour) waits for either an active-state transition OR a subtask_id advance — 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 to phase_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 to pending so 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 existing gcode_file-changed discriminator (#1150) stays put for the no-subtask-id-advance case. Tests: test_scheduler_watchdog.py 14 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; new test_reverts_when_subtask_advanced_but_state_never_active pins the #1678 wedge case (subtask_id advances, state stays IDLE for the full Phase B window → revert + NO force_reconnect call); new test_default_phase_b_timeout_is_180_seconds pins 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" with sw.js stuck in activating for the entire session. Root cause: the client.navigate(client.url) call added to the activate handler in sw.js (commit 18d534c9, 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 was client.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's event.waitUntil deadlocked on await client.navigate(...) because the SW intercepts its own document fetch while still activating, the document load aborts, and the SW never reaches activated. 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.js activate 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 to sw-register.js: capture hadController = !!navigator.serviceWorker.controller at script load (true ⇔ a previous SW was controlling the document), listen for controllerchange, and only location.reload() when hadController was 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_NAME bumped bambuddy-v29 → bambuddy-v30 and STATIC_CACHE bambuddy-static-v28 → bambuddy-static-v29 so existing browsers fetching the new sw.js drop 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 of sw-register.js is unchanged (still wipes registrations on /spoolbuddy paths). The notificationclick handler in sw.js (open-tab-on-push) still uses client.navigate(url) — different code path, unrelated, unchanged.
  • VP archive/queue names with & no longer render as &amp;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 showed PCB Vise &amp;amp; Solder Station where the 3MF's Title metadata is PCB 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 called html.unescape(). The raw &amp; landed in the DB; React then auto-escaped the & again on render, producing &amp;amp;. The sibling parser ProjectPageParser (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-level import html and the same loop-until-stable unescape pattern in _parse_3dmodel, applied uniformly to all <metadata> values so Title, Designer, and any future fields all get peeled the same way. The loop terminates as soon as html.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) reads BBL_DESIGNER_MODEL_TITLE_TAG (defined as "Title" in bbs_3mf.hpp) from the 3MF, slugifies it (space → _, unusable chars <>[]:/\|?*"_, collapse runs of _, truncate to 100 chars), and unconditionally overwrites the user-typed m_project_name with it before sending. params.project_name becomes both the FTP filename and the MQTT subtask_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 in test_archive_service.py::TestThreeMFMetadataHTMLUnescapeTitle with &amp; unescapes to & (the reporter's exact case), Title with triple-encoded &amp;amp;amp; peels all three layers (the BambuStudio worst-case ProjectPageParser already documents), plain Title=Benchy passes 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: host not viable because other containers already bind the same ports) measured 2002 docker-proxy host processes spawned from the previously-exposed 50000-51000:50000-51000 range — one process per port per address family, ~3.5 MB RSS each, ~3.5 GB total that doesn't show up in docker stats because it's host-level not container-level. Root cause: shared port pool, treated as symptom not cause. VirtualPrinterFTPServer exposed PASSIVE_PORT_MIN/MAX as 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_port and 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-level compute_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 takes passive_port_min / passive_port_max instance args. manager.py computes the slice at server-construction time from self.id and 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 beyond PASSIVE_MAX_SLOTS = 100 wrap 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.yml now exposes 50000-50029:50000-50029 by default (covers 3 VPs out of the box) instead of the 1001-port range. The comment explains how to widen for more VPs (50000-500N9 for N = vp_count - 1) and that proxy-mode VPs still need 50000-50100:50000-50100 because proxy mode forwards the real printer's full range — that codepath uses a separate TCPProxy.FTP_DATA_PORT_MIN/MAX and isn't sliced (the real printer owns that range, not Bambuddy). Doc corrections in the same drop: the previous warning over-stated userland-proxy: false as "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 in test_vp_ftp_port_slicing.pycompute_passive_port_slice pins: 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 crash asyncio.start_server). Two VirtualPrinterFTPServer instance 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 at test_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 .3mf to 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 calls printer_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 by background_dispatch.py:747/943 (the Archive→Print and Library→Print flows). Queue dispatch had no equivalent hop, so _print_user_info was always None and the PrintLogEntry's created_by_username landed NULL. Fix (two-sided): (1) print_queue.py /start now binds the auth dep to user: User | None and writes item.created_by_id = user.id when user 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 in auth.py::require_ownership_permission). (2) print_scheduler.py gains a small _propagate_owner_to_printer_manager helper, called from _start_print immediately after register_expected_print: when item.created_by_id resolves to a real User row, it forwards (printer_id, owner.id, owner.username) into printer_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 in test_queue_start_user_attribution.py — three route tests pin (a) authenticated /start writes created_by_id on an unattributed item, (b) an existing owner is preserved when a different user clicks /start, (c) auth-disabled leaves created_by_id=NULL (no synthetic placeholder user invented); three helper tests pin (d) the propagation forwards the resolved username into set_current_print_user, (e) a None owner is silently skipped, (f) a missing User row is silently skipped instead of raising. Full 63-test test_print_queue_api.py suite 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 maxHeight against CSS 100vh (PrintersPage.tsx:5443) and positions itself using window.innerHeight (via computePopoverPosition, 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 assumed 100vh matched the visible viewport. Fix: two-line change. (a) frontend/src/pages/PrintersPage.tsx:5443 switches maxHeight: calc(100vh - …)calc(100dvh - …) so the dynamic viewport units shrink with iOS toolbars. (b) frontend/src/utils/popoverPosition.ts:53 defaults viewportHeight from window.visualViewport?.height ?? window.innerHeight so 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 the shrink-0 footer stays pinned to the visible bottom — the Start Drying button is reachable in both cases. Tests: 3 new in popoverPosition.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 explicit viewportHeight override 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_success no 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 marked skipped with Previous 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_success in services/print_scheduler.py): (a) The lookback query .in_(["completed", "failed", "skipped", "aborted"]) excluded cancelled, 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 INCLUDED skipped, 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 to prev_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"); skipped is excluded so the query always walks back to the most recent REAL print attempt and failed / aborted still gate as before. Conservative recovery migration: a one-shot pass in core/database.py::run_migrations resets only the skipped items whose immediate real predecessor (by completed_at desc, excluding the skipped-cascade itself) was cancelled — same fingerprint as the bug, narrow enough not to disturb skipped items whose true predecessor was a real failed / aborted print. Items match on status='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 in test_check_previous_success.py pin 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 in test_cancellation_cascade_recovery_migration.py pin 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/all returns HTTP 403 cf-mitigated=challenge — Cloudflare upped the bot-protection on bambulab.com to 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. The Accept / Accept-Language header workaround we shipped for #1350 was below-HTTP and no longer enough. Existing users with a build_id.json on 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: use curl_cffi for the two bambulab.com fetches only. New dependency added to requirements.txt; firmware_check.py lazy-initialises a curl_cffi.requests.AsyncSession(impersonate="chrome", ...) for the bambulab.com calls (the index page that carries the Next.js buildId, and the per-model _next/data/{buildId}/.../{api_key}.json endpoint). Smoke-tested end-to-end against the live page: returns 200 OK + valid buildId, 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 to Bambuddy/1.0 (+https://github.com/maziggy/bambuddy) via the session's headers= 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 the headers= 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: if curl_cffi fails 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 test test_bambulab_get_falls_back_to_httpx_when_curl_cffi_missing pins 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_get instead 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.yml used build: { context: https://github.com/maziggy/orca-slicer-api.git#bambuddy/profile-resolver }, which requires git in the Docker BuildKit worker — Container Station and Synology DSM don't ship git there, so the build fails immediately with exec: "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 to ghcr.io/afkfelix/orca-slicer-api:latest-orca2.3.0 ran but couldn't slice — that image lacks the bambuddy/profile-resolver patches (the inherits: chain resolver, the from: "User""system" rewrite, the # clone-prefix strip, and the sentinel-value strip), so /profiles/bundled returned 400 and /slice returned Invalid 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 for bambu-studio-api) via a new docker-publish-sidecars.sh helper in the orca-slicer-api repo; 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 on bambuddy/profile-resolver AND the working tree is clean, and never executes git checkout / pull / fetch / reset itself. slicer-api/docker-compose.yml switches from build: to image: ghcr.io/maziggy/orca-slicer-api:${SIDECAR_TAG:-latest}. New SIDECAR_TAG env var in .env.example defaults to latest; set SIDECAR_TAG=bambuddy-X.Y.Z to pin to the sidecar image that shipped with a specific Bambuddy release. Scope limitation: both images are linux/amd64 only. The OrcaSlicer multi-arch path stays on hold pending an upstream extraction fix — the kldzj/orca-slicer-arm64 AppImage's --appimage-extract silently fails under QEMU build emulation; the Dockerfile's ;-chained RUN block masked the failure until the final COPY squashfs-root tripped. 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.md and wiki/features/slicer-api.md rewrote the Quick start, Updating, and Sidecar source sections — docker compose up -d now pulls instead of building, and docker compose pull && docker compose up -d is the new update path (no --no-cache --pull dance because Compose only ever sees image: 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 every create / update (any explicit access_code submitted 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 same inheritsAccessCodeFromTarget predicate gates a small "Inherited from target" badge in place of the existing isSet / notSet status 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 in core/database.py corrects 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.md line 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 :322 annotated 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 of create_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: new backend/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 a name= argument so a leak source is traceable in /tracebacks and the log line. Migration: the 16 truly-orphan asyncio.create_task(...) call sites — across main.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), and background_dispatch.py (2 lambda-wrapped) — switched to spawn_background_task. Other create_task sites already kept strong refs via self._tasks.append(...), self._x_task = ..., or local await/gather and stay unchanged. Tests: 5 unit cases in test_tasks.py pin the contract — strong-ref retention through completion, set-shrinkage after done, uncaught exception logged at WARNING with exc_info, cancellation does not log (a shutting-down service is not an error), and named tasks propagate name=. 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, the bambu_cloud.Login failed mis-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), and PFCN… (cloud shared / partner-uploaded — e.g. Polymaker's "(Custom)" Bambu Lab H2D variants like the reporter's PFCN80e80c1f79db85). apply_spool_to_slot_via_mqtt only routed GFS and PFUS through the cloud-detail lookup that extracts the real filament_id. PFCN slipped past the cloud-lookup branch, fell into the local-preset int() parse path, raised ValueError, dropped into normalize_slicer_filament which returns any P-prefix unchanged, and the raw PFCN landed in tray_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 own getCloudSettingDetail lookup and writes the resolved filament_id. Fix: extend the cloud-detail-lookup branch (inventory.py:129) and the discard safety net (inventory.py:223) to include PFCN alongside GFS/PFUS. After the fix, the same three paths work: cloud-authenticated → real filament_id from detail["filament_id"] ships as tray_info_idx (Polymaker PLA Matte resolves to GFL05); 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 in test_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 resolved filament_id while preserving the original PFCN as setting_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 now Bambu 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 the localPreset always-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 in frontend/src/utils/slicerPrinterMatch.ts. New PRINTER_MODEL_SUFFIX_ALIASES table holds the bidirectional A1 MiniA1M mapping (uppercase-normalised, narrow on purpose — wide-net aliasing like X1X1C would silently group truly distinct printers); exported matchesPrinterModelSuffix(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 via mapModelCode(printer?.model)), and slicerPrinterMatch.ts:classifyByBambuName (the SliceModal Process / Filament compatibility check). Backend PRINTER_MODEL_MAP also gains a Bambu Lab A1MA1 Mini entry 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/settings is the place to grep, called out in the source comment. Tests: 7 new unit cases in slicerPrinterMatch.test.ts pin 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 in presetCompatibility (cloud filament BBL A1M matches A1 Mini, cloud process BBL A1M matches A1 Mini, BBL A1M does NOT match A1). 2 new component-level cases in ConfigureAmsSlotModal.test.tsx: BBL A1M cloud preset surfaces when picker is for A1 Mini (with BBL A1 correctly filtered out), and BBL X1C stays 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 → set gcode_state=FINISH, and the slicer's "Downloading" UI releases on FINISH. Bambu Studio 2.7.x flipped the Send sequence to FTP verify_job → FTP .3mf → MQTT project_file, so on_file_received's set_gcode_state("FINISH", …) fires first, then the synthetic _send_print_response ack runs and overwrites _gcode_state back 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_report overrides the cached push's gcode_state with the local _gcode_state (still PREPARE), so the real state changes never reach the slicer. The fix re-fires set_gcode_state("FINISH", filename, prepare_percent="100") from on_print_command 1.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 in test_virtual_printer.py — schedules on archive (and by extension queue/review), proxy mode does NOT schedule, no-MQTT skip is silent, second project_file cancels 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 (at layer_num >= total_layer_num while 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.1 and case_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 the capture_finish_photo setting before each start_print call (reprint + library-file flows both wired) and, when the user did NOT opt in to timelapse for this print, overrides timelapse=True on the MQTT command + marks the new PrintArchive.bambuddy_forced_timelapse column True. User-opted-in timelapses pass through unchanged (no override needed). Migration adds the column branched on is_sqlite() for the boolean default (DEFAULT 0 on SQLite, DEFAULT FALSE on Postgres — PG rejects DEFAULT 0 for BOOLEAN). New module-level extract_video_last_frame(video_path, output_path) in services/camera.py runs a single ffmpeg -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) polls archive.timelapse_path every 3s for up to 60s — _scan_for_timelapse_with_retries runs in parallel and writes that field when the FTP download finishes. When it lands and the file exists on disk, extract the last frame as finish_<timestamp>_<hex>.jpg. _background_finish_photo now tries the timelapse path first when timelapse_was_active=True and 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: when archive.bambuddy_forced_timelapse is 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, clears archive.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 bypass background_dispatch.py, so the force-on doesn't fire and the live-cam fallback (bed-down) still applies for those. Can add the mid-print M981 S1 P20000 MQTT toggle in on_print_start later 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 in test_extract_video_last_frame.py cover 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 in test_finish_photo_from_timelapse.py cover 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 in test_dispatch_force_timelapse.py and test_cleanup_forced_timelapse.py pin: override fires only when capture_finish_photo enabled AND user-timelapse off; user-opted-in timelapse passes through unchanged; cleanup deletes local + clears timelapse_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.0 seek 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 to ffmpeg -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. New test_extracts_correctly_from_sub_second_video regression test pins it against a 0.5 s synthetic MP4. (b) The dispatch-time override only wired into background_dispatch.py (Print Now / Reprint flows), but the print queue runs through a separate scheduler at print_scheduler.py:_start_print which calls printer_manager.start_print directly. Refactored the resolver to a module-level resolve_effective_timelapse(db, archive, user_wanted_timelapse) function and wired it into the scheduler's call site too. New test_scheduler_force_timelapse_wiring.py walks the scheduler's AST and asserts the start_print(timelapse=...) kwarg references effective_timelapse (not item.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/24printer 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 bound INADDR_ANY so this was never an interface-bind issue — only a routing-boundary one. The fix surfaces an always-visible subnet picker in AddPrinterModal: 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 through POST /discovery/scan with the typed CIDR instead of POST /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 to localStorage under bambuddy.discovery.customSubnet and restored on next modal open, so a user who maintains a VLAN setup doesn't retype 10.1.1.0/24 every 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/scan already 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 in PrintersPageDiscoveryCustomSubnet.test.tsx — picker renders on native installs (was Docker-gated before), picking Custom + entering a CIDR routes through discoveryApi.startSubnetScan not startDiscovery and persists the choice via localStorage.setItem, picker default (the detected interface subnet) still triggers SSDP via startDiscovery. AddPrinterModal exported from PrintersPage.tsx so the tests can mount it directly without round-tripping through the full page (same shape as ProjectModal for 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 no max-h and no overflow. 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-col on the card (the 2rem accounts for the outer p-4), a flex-1 overflow-y-auto min-h-0 wrapper around the form fields, and the Cancel / Save buttons moved into a flex-shrink-0 sibling with a border-t separator so they become a sticky footer that's always visible regardless of scroll position. The buttons stay inside the <form> so type="submit" still works. 2 new vitest cases in ProjectsPage.test.tsx pin the structural fix: the Save button is NOT a descendant of the overflow-y-auto region (otherwise it would scroll off again) and the modal card carries the max-h-[calc(100vh-2rem)] cap. Other modals in the codebase with the same fixed inset-0 flex items-center justify-center + max-w-md shape 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/files gains two mutually-exclusive query flags, internal_only and external_only, filtering directly on LibraryFile.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: new topLevelView: 'internal' | 'external' state on FileManagerPage, default internal; the query passes the corresponding scope only when selectedFolderId === null. Sidebar shows the "External" row gated on folders.some(f => f.is_external); mobile selector dropdown carries a __top:internal / __top:external sentinel 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 in test_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 in FileManagerPage.test.tsx (External entry conditional on is_external, default internal-only query, External-click switches scope). Existing 48 FileManagerPage tests + 11 FileManagerExternalFolder tests 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/pull profile-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 parallel OrcaCloudProfilesView component. We chose paste-flow rather than a clean OAuth callback because Orca's Supabase project only honors localhost in its redirect_to allowlist. Slicer integration: the unified-presets endpoint surfaces Orca Cloud as a 4th tier above Bambu Cloud > local > standard; _dedupe_by_name and the SliceModal dropdowns both updated to walk all 4 tiers. The dedicated _fetch_orca_cloud_presets extracts filament_type and default_filament_colour inline from each profile's content (cheap because /sync/pull returns 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 separate CloudStatusBanner instance shows Orca Cloud's auth status independently of Bambu's. AMS slot integration: ConfigureAmsSlotModal accepts orca_cloud as a new preset source (prefixed orca_<UUID> to match the existing local_* / builtin_* convention), gracefully tolerating raw UUIDs from historical saves; Orca presets are treated like local imports for tray_info_idx derivation (no Bambu setting_id, generic filament-ID map by parsed material). Slot mapping persisted with preset_source='orca_cloud'. SpoolBuddy integration: SpoolFormModal and SpoolBuddyWriteTagPage fetch Bambu + Orca filaments in parallel via Promise.allSettled and concat; ConfigureAmsSlotModal opens from SpoolBuddyAmsPage's Configure flow with Orca presets surfaced first. Storage: 8 new columns on users (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.com is 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.source extended to 'orca_cloud' | 'cloud' | 'local' | 'standard'; _resolve_orca_cloud li

Changelog truncated — see the full CHANGELOG.md for the complete list.

Don't miss a new bambuddy release

NewReleases is sending notifications on new releases.