Note
This is a daily beta build (2026-06-16). 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
- Admin-configurable session lifetime (#1706, reported by AD3DStuff) — The 24-hour session cap that ships with Bambuddy was an intentional security hardening (audit finding M-2 reduced it from 7 days), but the "Remember Me" checkbox only controlled storage location (localStorage vs sessionStorage), not session duration. iPhone PWA users and homelab admins on trusted networks were getting kicked out every 24 hours with no way to extend it. New setting:
session_max_hoursunder Settings → Users with three presets (24h / 7 days / 30 days) plus a custom field, hard-capped at 30 days (720h). Default remains 24h so existing deployments and the M-2 audit baseline are untouched until an admin opts in. The Settings card surfaces a yellow warning whenever the value exceeds 24h: "Longer sessions reduce automatic logout protection. Recommended only for trusted single-user deployments." Backend wiring: newresolve_session_max_minutes(db)helper inbackend/app/core/auth.pyreads the setting, clamps to [1h, 720h], and falls back to 24h on missing / blank / unparseable values. The helper is called at all four token-issuance sites — plain/auth/login, 2FA TOTP/email completion, 2FA backup-code completion, and OIDC callback — so a long-session policy works uniformly regardless of how the user authenticates. DB errors in the resolver are deliberately NOT caught: login is already inside a transaction and a broken DB must abort the login rather than silently extend or shrink the session lifetime. Defense-in-depthSESSION_MAX_HOURS_HARD_CEILING = 720clamps any tampered DB row above the Pydantic ceiling. Already-issued tokens keep their original expiry — the new setting only affects future logins, so an admin lowering the value can't retroactively revoke active sessions and an admin raising it can't retroactively extend them. What this does NOT change: the "Remember Me" checkbox still controls only storage location (cleared on browser close vs persisted across restarts). The relabel from misleading-UX-perspective is left for a separate follow-up — that's a UX choice independent of the session-policy mechanism. API tokens (MAX_TOKEN_LIFETIME_DAYS), camera stream tokens (60min), WebSocket tokens (60min), and slicer download tokens (5min) keep their own TTLs and are unaffected. Tests: 15 new cases inbackend/tests/integration/test_session_policy.pysplit across three classes.TestResolveSessionMaxMinutespins the clamping resolver — missing row, empty string, unparseable value, zero/negative, 1h minimum, 7-day passthrough, 30-day passthrough, above-ceiling clamp.TestLoginRespectsSessionPolicydecodes the JWTexpclaim end-to-end and asserts the token returned by/auth/loginhonours the configured ceiling for the default-24h, configured-7d, and above-ceiling-clamp cases.TestSettingsAPIExposesSessionMaxHoursround-trips the field through/settings/(default = 24, valid update persists as int's string form, zero rejected with 422, above-ceiling rejected with 422). Existing 202-case auth + MFA suite still green. i18n: 8 new keys insettings.sessionPolicy.*namespace; full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity check 5149 leaves per locale. ESLint clean;npm run buildclean; ruff clean.
Fixed
- SpoolBuddy inventory search now matches spool ID, slicer filament name, and storage location (#1738, reported by shaddowlink) — The reporter found that typing a numeric spool ID into SpoolBuddy → Inventory's search box returned no results, even though the same query in Bambuddy's main Inventory page worked. Root cause:
frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx:147-155reimplemented the search filter inline and only matchedmaterial,subtype,brand,color_name, andnote. The main Inventory page delegates to the sharedfilterSpoolsByQueryhelper infrontend/src/utils/inventorySearch.ts:7, which additionally matchesString(spool.id),slicer_filament_name, andstorage_location. SpoolBuddy had diverged. Fix: replace the inline filter with a single call tofilterSpoolsByQuery(list, searchQuery.trim()). Both inventory modes (internal viagetSpools, Spoolman viagetSpoolmanInventorySpools) return the sameInventorySpoolshape, so this covers both paths in one drop. SpoolBuddy now matches Bambuddy's search behaviour across all eight fields. Tests: newSpoolBuddyInventorySearch.test.tswith 4 cases pinning the parity — exact spool ID match, partial spool ID match, the five pre-fix fields still match, and the three newly-included fields (storage_location, slicer_filament_name, plus implicit id) match. ExistinginventorySearch.test.tsID matching test (#1336) still green. ESLint clean;npm run buildclean. No backend change, no i18n, no new permission. - Sidebar entries for Files / Archives / Queue no longer hide from non-admin users with granular read access (#1755, reported by knifesk) — The reporter noticed the File Manager sidebar entry was hidden for a default Operators user even though the same user could load
/filesdirectly and the backend API accepted their requests. Root cause is broader than reported:frontend/src/components/Layout.tsx::navPermissionsmappedfiles → 'library:read',archives → 'archives:read',queue → 'queue:read'— the LEGACY permission flags — but the default Operators group atbackend/app/core/permissions.py:368-380is seeded with the GRANULAR variants only (ARCHIVES_READ_OWN.value,QUEUE_READ_OWN.value,LIBRARY_READ_OWN.value). The migration path atbackend/app/core/database.py:3034-3041also flips legacy*:read→*:read_ownon existing non-admin groups. So a non-admin user never holds the legacy permission,hasPermission('library:read')returns false, sidebar entry is suppressed — for all three resources, not just Files. Admins getALL_PERMISSIONSwhich includes the legacy variant, so the sidebar always renders for them, which is why this regression went unnoticed until a real non-admin Operator account landed in #1755. Fix:navPermissionsnow acceptsPermission | Permission[]and the three affected resources list all three tiers (*:read,*:read_own,*:read_all). TheisHiddencheck switches on the array type —some(hasPermission)for arrays, current behavior for single values. Nothing else in the gate logic changed.frontend/src/api/client.tsPermission type extended with the missing granular variants (archives:read_own,archives:read_all,queue:read_own,queue:read_all,library:read_own,library:read_all) — these existed in the backend enum and were already being shipped to the frontend in/auth/me, but the TS type didn't declare them so any new code wanting to gate on the granular tier would TypeScript-error. What this also fixes downstream: any future feature that needs to gate UI on*:read_own/*:read_allcan now do so without re-adding the same type entries. Tests: 5 new cases inLayout.test.tsx::'Sidebar gate accepts granular read tiers (#1755)'— Files visible with onlylibrary:read_own, Files visible with onlylibrary:read_all, Archives visible with onlyarchives:read_own, Queue visible with onlyqueue:read_own, and the negative case (printers:readonly — none of Files / Archives / Queue render). 22/22 Layout vitests green; ESLint clean;npm run buildclean. No backend change, no DB migration, no new i18n keys. No new permission — just unmasks UI for users who already had backend access. - Push notification for "Printer offline" now actually fires (#1752, reported by saint-hh) — The notification provider's
on_printer_offlinetoggle has shipped since the notifications feature landed: schema field, DB column,notification_template.pyentry, and the dispatcherNotificationService.on_printer_offline(printer_id, printer_name, db)are all in place. What was missing was the caller — nothing in the codebase actually invoked the dispatcher when a printer went offline. The reporter (P2S, smart-plug-cuts-power scenario) confirmed turning the toggle on did nothing; only the print-failure notification fired when power was restored, via the firmware'sgcode_state=FAILEDreport on MQTT reconnect. Why the toggle was orphan: every other provider event (on_print_start,on_print_complete,on_print_progress,on_printer_error, etc.) has a clear call site undermain.py::on_printer_status_changeor alongside the print-lifecycle hooks. The offline event was the only edge-triggered toggle without one — the dispatcher and template predated the wiring step and were silently shipped. Both upstream offline-trigger paths (smart_plug_manager→printer_manager.mark_printer_offline()andbambu_mqtt.py::check_stalenessafter the 30s STALE_RECONNECT_COOLDOWN) route through_on_status_changealready and reachon_printer_status_change; the handler just didn't act on the disconnect edge. Fix: edge detection inon_printer_status_changewatchesstate.connectedagainst the previous observation per printer (_printer_last_connected: dict[int, bool]). On the True → False transition it schedules_maybe_notify_printer_offline(printer_id)as a background asyncio task; on the next True observation it cancels any pending task. The helper sleeps_PRINTER_OFFLINE_NOTIFY_DEBOUNCE_SECONDS = 60.0then re-checksprinter_manager.is_connected(printer_id)— only fires the notification if the printer is still offline. Why 60s debounce: sized againstbambu_mqtt.py::STALE_RECONNECT_COOLDOWN = 30s— a single stale-trigger + reconnect cycle isn't enough to fire, only a real outage that survives one full cooldown notifies. Transient MQTT blips (WiFi roam, broker reload, brief packet loss) recover within the window and the cancellation path kicks in. Edge-case handling: initial observation with no prior connected state doesn't fire (covers Bambuddy startup with an already-offline printer); a False → False repeat doesn't reschedule (the in-flight task stays in place rather than resetting the clock on every status callback, which would otherwise mean the notification never fires); the task entry pops from_printer_offline_notify_tasksin the finally block whether the notification fired, the printer reconnected, or the task was cancelled mid-await. No symmetricon_printer_onlineevent: the reporter explicitly noted the "printer lost power and interrupted the print" notification already fires when power is restored — that's the print-failure notification, triggered by the firmware reportinggcode_state=FAILEDfor the interrupted print on MQTT reconnect. That covers the "printer is back" channel without a new toggle. If the user then resumes the print, no print_start notification fires (Bambuddy'sbambu_mqtt.py:3039explicitly suppressesis_new_printfor PAUSE → RUNNING to prevent duplicates when resuming from pause), but that's a separate scope from offline-detection. Tests: 9 new cases intest_printer_offline_notification.pysplit across two classes.TestMaybeNotifyPrinterOfflinepins the debounced helper: fires notification when still offline at end of window, doesn't fire when printer reconnected during debounce, doesn't fire when the printer disappeared from the DB (uninstall mid-window), clears_printer_offline_notify_tasks[printer_id]after run.TestOfflineEdgeDetectionpins the edge logic insideon_printer_status_change: first observation (connected) doesn't schedule, first observation (disconnected) doesn't schedule (the no-prior-True case — important for startup), True → False schedules a task, reconnect cancels the pending task, repeated False observations don't replace the in-flight task. Full backend suite still green; ruff clean.