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

pre-release8 hours ago

Note

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

Docker users: Update by pulling the new image:

docker pull ghcr.io/maziggy/bambuddy:daily

or

docker pull maziggy/bambuddy:daily


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

Security

  • Vite 7 → 8 major bump — Bambuddy's frontend now builds with Vite 8 (^7.3.2^8.0.16) and the matching plugin-react release (vitejs/plugin-react ^5.1.1^5.2.0). Headline architectural change: Vite 8 swaps Rollup for Rolldown as the default bundler — same plugin contract, Rust-backed core, slightly different chunk layout / output bytes (no functional regression). The bump also lifts the transitive esbuild floor to 0.28.1, which closes the last open advisory in the audit chain. Bambuddy-side surface audited: vite.config.ts uses only stable contracts that survived the v8 cut — defineConfig, the Connect type, the custom serveGcodeViewer configureServer middleware plugin (proxies /gcode-viewer/* to the repo's sibling gcode_viewer/ directory in dev), the server.proxy with WebSocket upgrade for /api/v1/ws, build.outDir/emptyOutDir/chunkSizeWarningLimit, and resolve.alias for ``. base: '/' regression guard from #1221 is unaffected. No SSR, no library mode, no CSS preprocessors, no exotic plugins. `vitest4.1.8` already accepts vite 8 in its peer range (`^6 || ^7 || ^8`); no test-runner bump required. Node: vite 8 requires `^20.19.0 || >=22.12.0`; CI Node 20.x line satisfies this. What this is NOT: plugin-react v6 — that line requires `babel-plugin-react-compiler` + `rolldown/plugin-babel` as peers and is a separate scope. `npm run build`, `npm run lint`, `npx vitest run` all clean; `npm audit` clean.
  • Frontend dependency bumps — Routine version updates across the runtime, build, and test dependency surface. Runtime: dompurify 3.4.0 → 3.4.10. package.json floor raised from ^3.4.0 to ^3.4.10 so fresh installs cannot land on the deprecated 3.4.4 release. Three call sites use string-output sanitisation (frontend/src/pages/MakerworldPage.tsx, frontend/src/pages/ProjectDetailPage.tsx, frontend/src/components/ProjectPageModal.tsx); release notes 3.4.1 → 3.4.10 reviewed for behavioural changes — 3.4.4 widened the default allow-list with selectedcontent + command + commandfor (all valid modern HTML, harmless for our two default-allow-list call sites), and ProjectPageModal is unaffected anyway because it sets an explicit ALLOWED_TAGS / ALLOWED_ATTR whitelist. Build / lint / test tooling (transitive, dev-only): babel/core 7.29.0 → 7.29.7 (pulled by vitejs/plugin-react and eslint-plugin-react-hooks), vite 7.3.2 → 7.3.5, markdown-it 14.1.1 → 14.2.0 (pulled by tiptap/extension-linktiptap/pmprosemirror-markdown; Bambuddy never calls markdown-it.render directly so the change is transparent), js-yaml 4.1.1 → 4.2.0 (pulled by eslint), form-data 4.0.5 → 4.0.6 + ws 8.20.1 → 8.21.0 (both pulled by jsdom in the test runtime). All bumps inside existing semver ranges except dompurify. No source changes required.

Added

  • Centralised sidebar layout + per-page hide toggles (#1673, contributed by EdwardChamberlain) — Sidebar item ordering and visibility move from inline Layout.tsx state to a dedicated module so the same persistence rules apply whether the user is reordering with drag-and-drop, toggling an item off, or accepting the admin-pushed default. New frontend/src/utils/sidebarLayout.ts owns the localStorage round-trip (sidebarOrder + sidebarHiddenSystemItems keys), the SIDEBAR_LAYOUT_CHANGED_EVENT cross-tab refresh broadcast, and the isExternalSidebarItemId helper that distinguishes the new ext-* external link prefix from built-in nav. Hide / show toggle: every built-in sidebar entry (Printers / Inventory / Archives / Queue / Projects / File Manager / Makerworld / Profiles / Maintenance / Statistics — Settings is intentionally non-hideable) now carries an eye icon in the Sidebar settings card; click it to drop that entry from the rendered sidebar. Hidden IDs persist per-user via localStorage so personal taste survives reloads without leaking to other users on a shared install. Re-show by clicking the eye again. The previous drag-to-reorder UX is retired in this PR — the hide list + admin default order cover the same "I never use the Stats page" / "give me Files first" needs without the affordance ambiguity of the rearrange handle. Admin default order: new default_sidebar_order setting (validated server-side at backend/app/schemas/settings.py:533+) holds a JSON object {order: string[], hiddenSystemItemIds: string[]} that admins set once from Settings → General → Sidebar (Set Default toggle). On first login per user, Layout.tsx's useEffect reads the admin default, filters it against the current defaultNavItems + valid external IDs (so a deleted external link or a removed built-in doesn't strand in someone's stored order), applies it locally, and records a per-user sidebarDefaultApplied_<user_id> localStorage flag so the default is one-shot — later user-driven changes aren't clobbered on every login. Settings card: ExternalLinksSettings.tsx is the single source of truth for the Sidebar card (card-sidebar-links) in Settings → General. The header now carries the Set Default toggle (visible only when the caller holds settings:write), a Reset button (clears both sidebarOrder + sidebarHiddenSystemItems to defaults), and the Add Link button (opens the external-link create modal). The body lists every sidebar item — built-in or external — with the eye toggle inline on each row. The header row uses flex-wrap on the outer container and the right-side control group so the Add Link button doesn't overflow the card's right edge when Column 3 sits at its narrow lg:max-w-sm (384px) width. Settings → General reordering (post-merge polish): the Updates card moved to the top of Column 3 (above the new Sidebar card); the Data Management card moved to the bottom of Column 2 (after Library Auto-Purge) so the General tab balances better with the new Sidebar card taking column 3's vertical real estate. Anchor IDs card-updates, card-data, card-sidebar-links are preserved so deep-links + the in-app registerSettingsSearch index still resolve. Layout merge edge case: the PR's refactor of Layout.tsx::isHidden accidentally dropped the dev-side notifications gate (!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || settings?.user_notifications_enabled === false) and its advancedAuthStatus useQuery. The merged shape keeps three gates in priority order — hiddenSystemItemIds.includes(id) first (cheapest, explicit user intent), then the array-aware navPermissions check from #1755 (granular *:read_own / *:read_all tiers), then the notifications-specific gate — so a user without advanced auth doesn't suddenly see the Notifications entry. Backend: default_sidebar_order settings field accepts both shapes (plain array OR {order, hiddenSystemItemIds} object) for backward compat with installs that saved an array under an earlier draft of this work. Validator rejects any hiddenSystemItemIds that isn't a list[str] with 422. Tests: 17 new backend cases in test_sidebar_settings.py pinning the validator (empty / JSON-array / JSON-object / mixed-types / hostile shapes). Frontend: 5 new Layout.test.tsx cases pinning the hide-toggle behaviour (hidden ID drops the entry, hidden ID for Settings is ignored — settings is non-hideable, eye-click round-trips through localStorage, SIDEBAR_LAYOUT_CHANGED_EVENT triggers a re-read across tabs) and 255 added/changed lines in SettingsPage.test.tsx covering the admin-default toggle and the eye-icon visibility column. i18n: new keys in the externalLinks.* namespace (sidebarLayout / sidebarLayoutDescription / visibleInSidebar / hiddenFromSidebar / requiredInSidebar / setDefault / etc.), full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5168 leaves per locale. Vitest test timeout raised in vitest.config.ts to absorb the userEvent.setup({delay: null}) cases in the heavier SettingsPage flows. Full vitest run green; ESLint clean; npm run build clean; ruff clean.
  • Structured storage locations catalog (#1505 closing #1004, contributed by Poltavtcev) — Inventory gets a first-class catalog of physical storage spots (shelves, drawers, dryboxes) instead of free-text in the spool's storage_location field. Spools now carry a location_id FK alongside the denormalized storage_location string (kept for Spoolman wire format + label rendering). The Inventory page picks up a Locations button that opens an in-page modal — the original PR landed a standalone /inventory/locations page; merged shape is a modal opened from Inventory so the catalog read sits next to the spool list. The modal handles create / edit / delete / pick-to-filter; row-click pushes the location_id into the Inventory filter state without a navigation. Deep-link ?location_id=<n> (and ?location_id=__none__ for the unset bucket) still works for sharing or bookmarking. Backend: new Location model + locations table with case-insensitive name_key (LOWER(TRIM(name))) UNIQUE — concurrent creates on the same name resolve to a single 409 via the IntegrityError → re-fetch shape in _create_location_or_get_existing. CRUD at /api/v1/inventory/locations, all five routes gated with RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ|UPDATE). Delete is blocked while spool_count > 0 so the user can't strand spools. Single-write-path is location_service::resolve_spool_location_fields() — both the internal-mode and Spoolman-mode spool routes feed through it so location_id and storage_location can never drift. Spoolman parity: location names sync into the local catalog on GET /spoolman/inventory/spools via maybe_sync_spoolman_locations; rename cascades to every Spoolman spool via client.rename_location, with a per-spool PATCH fallback when the upstream's bulk endpoint isn't there (Spoolman <0.16 doesn't expose PATCH /location/{name} and returns 404/405). get_distinct_locations normalises both the older list[str] and the newer list[dict] Spoolman payload shapes. Migration: inline in database.py::run_migrations — creates the locations table (DATETIME for SQLite / TIMESTAMP for Postgres), adds spool.location_id FK + index, then backfills the catalog from existing free-text values (GROUP BY LOWER(TRIM(storage_location)) so case variants like Drybox 1 and DRYBOX 1 collapse into one row). The legacy name_key backfill runs BEFORE the dedup INSERT so a pre-existing locations row with NULL name_key (manually inserted before this feature shipped) gets its column populated first and the subsequent spool-link UPDATE can join on it. Post-migration warn-log flags any spools that still carry free-text storage_location with no location_id — surfaces the rare mis-link case to ops instead of silently leaving them out of catalog filters. Rename safety: Spoolman PATCH runs BEFORE db.commit(), cascade failure rolls back the local rename and raises HTTP 502 — without this ordering a partial failure left the catalog and Spoolman's per-spool location field permanently diverged (the next sync recreates the old name as a duplicate catalog row). Legacy-row UPDATE matches func.lower(func.trim(Spool.storage_location)) == old_name.strip().lower() so the SQL TRIM symmetry holds for whitespace-padded values. Cross-tab refresh: spoolman_inventory.py now emits inventory_changed on the 8 spool-mutating routes (create, bulk-create, update, delete, archive, restore, reset-bulk, weight, tag) — internal mode already broadcast in 12 places, Spoolman mode silently degraded before. The useWebSocket handler invalidates inventoryLocationsQueryKey on every such message so location counts stay in sync across tabs. Performance: the Spoolman→catalog sync used to fire on every GET /spools request, hit Spoolman, and open a write transaction; now guarded by a 60s per-URL TTL cache (_spoolman_location_sync_last_run) so a polling UI doesn't burn a Spoolman round-trip + SQLite write per refetch. The route also passes its already-resolved client through to the sync so test fixtures that patch the route module's client also catch the sync's client lookup — without this the SSRF LAN-topology parametrize tests took ~45s on real TCP timeouts to RFC-1918 IPs (now 2.79s in isolation). Frontend: SpoolFormModal location dropdown sends location_id only (same shape in both inventory modes — no spoolmanMode ? ... : ... UI gate) and the onCreateLocation flow surfaces ApiError.message instead of a generic toast so 409 / 400 / 500 stay distinguishable. LocationsModal passes isLoading to ConfirmModal during delete so a mid-mutation cancel can't strand a toast on a dismissed dialog; Pencil / Trash icon buttons carry aria-label for SR announcement. i18n: new locations.* namespace (20 keys: title, subtitle, add, edit, delete, empty, name, spools, manage, createPlaceholder, nameRequired, created, updated, deleted, saveFailed, deleteFailed, deleteBlocked, confirmDelete, confirmDeleteMessage, editAria/deleteAria), full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5168 leaves per locale. Tests: ~26 new across backend/tests/unit/test_location_service.py (rename strip/lower symmetry, sync-from-Spoolman log-on-unavailable, list[dict] payload normalisation), backend/tests/unit/test_spoolman_inventory_methods.py (get_distinct_locations shape guard × 4, rename_location bulk-then-fallback × 4 — 200 / 404 / 405 / 5xx), backend/tests/unit/test_location_migration.py (NULL + whitespace-only storage_location skip, legacy NULL name_key ordering, case-variant dedup, idempotency), backend/tests/integration/test_locations_api.py (CRUD round-trip, rename cascade, IntegrityError → 409, PATCH/DELETE 404, auth-gate 401 on all five routes when auth_enabled=true), and frontend/src/__tests__/components/LocationsModal.test.tsx (12 cases: open=false renders nothing + no fetch, row click → onPickLocation + onClose, 2-level Escape dialog stacking, rename collision 409 toast, disabled delete on spool_count>0, etc.). Frontend useWebSocket.test.ts exercises the inventory_changed → invalidate ['inventory-locations'] round-trip. Full backend pytest 6025/6025 (67s with -n 30); frontend vitest 2141/2141; ruff clean; npm run build clean; ESLint clean; i18n parity green.
  • Admin-configurable session lifetime (#1706, reported by AD3DStuff) — The 24-hour session cap that ships with Bambuddy was an intentional security hardening (audit finding M-2 reduced it from 7 days), but the "Remember Me" checkbox only controlled storage location (localStorage vs sessionStorage), not session duration. iPhone PWA users and homelab admins on trusted networks were getting kicked out every 24 hours with no way to extend it. New setting: session_max_hours under Settings → Users with three presets (24h / 7 days / 30 days) plus a custom field, hard-capped at 30 days (720h). Default remains 24h so existing deployments and the M-2 audit baseline are untouched until an admin opts in. The Settings card surfaces a yellow warning whenever the value exceeds 24h: "Longer sessions reduce automatic logout protection. Recommended only for trusted single-user deployments." Backend wiring: new resolve_session_max_minutes(db) helper in backend/app/core/auth.py reads the setting, clamps to [1h, 720h], and falls back to 24h on missing / blank / unparseable values. The helper is called at all four token-issuance sites — plain /auth/login, 2FA TOTP/email completion, 2FA backup-code completion, and OIDC callback — so a long-session policy works uniformly regardless of how the user authenticates. DB errors in the resolver are deliberately NOT caught: login is already inside a transaction and a broken DB must abort the login rather than silently extend or shrink the session lifetime. Defense-in-depth SESSION_MAX_HOURS_HARD_CEILING = 720 clamps any tampered DB row above the Pydantic ceiling. Already-issued tokens keep their original expiry — the new setting only affects future logins, so an admin lowering the value can't retroactively revoke active sessions and an admin raising it can't retroactively extend them. What this does NOT change: the "Remember Me" checkbox still controls only storage location (cleared on browser close vs persisted across restarts). The relabel from misleading-UX-perspective is left for a separate follow-up — that's a UX choice independent of the session-policy mechanism. API tokens (MAX_TOKEN_LIFETIME_DAYS), camera stream tokens (60min), WebSocket tokens (60min), and slicer download tokens (5min) keep their own TTLs and are unaffected. Tests: 15 new cases in backend/tests/integration/test_session_policy.py split across three classes. TestResolveSessionMaxMinutes pins the clamping resolver — missing row, empty string, unparseable value, zero/negative, 1h minimum, 7-day passthrough, 30-day passthrough, above-ceiling clamp. TestLoginRespectsSessionPolicy decodes the JWT exp claim end-to-end and asserts the token returned by /auth/login honours the configured ceiling for the default-24h, configured-7d, and above-ceiling-clamp cases. TestSettingsAPIExposesSessionMaxHours round-trips the field through /settings/ (default = 24, valid update persists as int's string form, zero rejected with 422, above-ceiling rejected with 422). Existing 202-case auth + MFA suite still green. i18n: 8 new keys in settings.sessionPolicy.* namespace; full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity check 5149 leaves per locale. ESLint clean; npm run build clean; ruff clean.

Fixed

  • Local Presets page: deleted row stayed visible until refetch returned, allowing a second delete click → 404 — On the Slicer → Local Profiles page, clicking Delete → Confirm fired the DELETE /api/v1/local-presets/{id} request, then the onSuccess handler closed the confirmation modal and called queryClient.invalidateQueries({ queryKey: ['localPresets'] }) without awaiting it. The global QueryClient default staleTime: 1000 * 60 (App.tsx:78) doesn't block invalidateQueries from refetching, but the refetch is async — so for ~hundreds of ms the rendered table still showed the just-deleted row, and a quick re-click on the same row opened a fresh confirm dialog → second confirm → backend returns 404 (row already gone) → confusing error toast. Caught while reproducing #1713: log showed DELETE /api/v1/local-presets/42 → 200 followed by two → 404 for the same id within 4 seconds. Fix: Add an optimistic queryClient.setQueryData<LocalPreset[]>(['localPresets'], …) in frontend/src/components/LocalProfilesView.tsx::deleteMutation.onSuccess that filters the deleted row out of the cached list synchronously, then leaves the existing invalidateQueries calls in place to reconcile any drift. Row disappears the instant the DELETE returns 200, no re-click window. The same import path's importMutation doesn't need the same treatment because additions can't trigger the symmetric "row I just acted on is still there" → 404 loop. ESLint clean; npm run build clean; existing LocalProfilesView.test.tsx suite still green (no new test added — the bug is a render-timing window the existing render-based vitests don't observe; the existing onSuccess assertions still pass with the new optimistic write).
  • SpoolBuddy inventory search now matches spool ID, slicer filament name, and storage location (#1738, reported by shaddowlink) — The reporter found that typing a numeric spool ID into SpoolBuddy → Inventory's search box returned no results, even though the same query in Bambuddy's main Inventory page worked. Root cause: frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx:147-155 reimplemented the search filter inline and only matched material, subtype, brand, color_name, and note. The main Inventory page delegates to the shared filterSpoolsByQuery helper in frontend/src/utils/inventorySearch.ts:7, which additionally matches String(spool.id), slicer_filament_name, and storage_location. SpoolBuddy had diverged. Fix: replace the inline filter with a single call to filterSpoolsByQuery(list, searchQuery.trim()). Both inventory modes (internal via getSpools, Spoolman via getSpoolmanInventorySpools) return the same InventorySpool shape, so this covers both paths in one drop. SpoolBuddy now matches Bambuddy's search behaviour across all eight fields. Tests: new SpoolBuddyInventorySearch.test.ts with 4 cases pinning the parity — exact spool ID match, partial spool ID match, the five pre-fix fields still match, and the three newly-included fields (storage_location, slicer_filament_name, plus implicit id) match. Existing inventorySearch.test.ts ID matching test (#1336) still green. ESLint clean; npm run build clean. No backend change, no i18n, no new permission.
  • Sidebar entries for Files / Archives / Queue no longer hide from non-admin users with granular read access (#1755, reported by knifesk) — The reporter noticed the File Manager sidebar entry was hidden for a default Operators user even though the same user could load /files directly and the backend API accepted their requests. Root cause is broader than reported: frontend/src/components/Layout.tsx::navPermissions mapped files → 'library:read', archives → 'archives:read', queue → 'queue:read' — the LEGACY permission flags — but the default Operators group at backend/app/core/permissions.py:368-380 is seeded with the GRANULAR variants only (ARCHIVES_READ_OWN.value, QUEUE_READ_OWN.value, LIBRARY_READ_OWN.value). The migration path at backend/app/core/database.py:3034-3041 also flips legacy *:read*:read_own on existing non-admin groups. So a non-admin user never holds the legacy permission, hasPermission('library:read') returns false, sidebar entry is suppressed — for all three resources, not just Files. Admins get ALL_PERMISSIONS which includes the legacy variant, so the sidebar always renders for them, which is why this regression went unnoticed until a real non-admin Operator account landed in #1755. Fix: navPermissions now accepts Permission | Permission[] and the three affected resources list all three tiers (*:read, *:read_own, *:read_all). The isHidden check switches on the array type — some(hasPermission) for arrays, current behavior for single values. Nothing else in the gate logic changed. frontend/src/api/client.ts Permission type extended with the missing granular variants (archives:read_own, archives:read_all, queue:read_own, queue:read_all, library:read_own, library:read_all) — these existed in the backend enum and were already being shipped to the frontend in /auth/me, but the TS type didn't declare them so any new code wanting to gate on the granular tier would TypeScript-error. What this also fixes downstream: any future feature that needs to gate UI on *:read_own / *:read_all can now do so without re-adding the same type entries. Tests: 5 new cases in Layout.test.tsx::'Sidebar gate accepts granular read tiers (#1755)' — Files visible with only library:read_own, Files visible with only library:read_all, Archives visible with only archives:read_own, Queue visible with only queue:read_own, and the negative case (printers:read only — none of Files / Archives / Queue render). 22/22 Layout vitests green; ESLint clean; npm run build clean. No backend change, no DB migration, no new i18n keys. No new permission — just unmasks UI for users who already had backend access.
  • Push notification for "Printer offline" now actually fires (#1752, reported by saint-hh) — The notification provider's on_printer_offline toggle has shipped since the notifications feature landed: schema field, DB column, notification_template.py entry, and the dispatcher NotificationService.on_printer_offline(printer_id, printer_name, db) are all in place. What was missing was the caller — nothing in the codebase actually invoked the dispatcher when a printer went offline. The reporter (P2S, smart-plug-cuts-power scenario) confirmed turning the toggle on did nothing; only the print-failure notification fired when power was restored, via the firmware's gcode_state=FAILED report on MQTT reconnect. Why the toggle was orphan: every other provider event (on_print_start, on_print_complete, on_print_progress, on_printer_error, etc.) has a clear call site under main.py::on_printer_status_change or alongside the print-lifecycle hooks. The offline event was the only edge-triggered toggle without one — the dispatcher and template predated the wiring step and were silently shipped. Both upstream offline-trigger paths (smart_plug_managerprinter_manager.mark_printer_offline() and bambu_mqtt.py::check_staleness after the 30s STALE_RECONNECT_COOLDOWN) route through _on_status_change already and reach on_printer_status_change; the handler just didn't act on the disconnect edge. Fix: edge detection in on_printer_status_change watches state.connected against the previous observation per printer (_printer_last_connected: dict[int, bool]). On the True → False transition it schedules _maybe_notify_printer_offline(printer_id) as a background asyncio task; on the next True observation it cancels any pending task. The helper sleeps _PRINTER_OFFLINE_NOTIFY_DEBOUNCE_SECONDS = 60.0 then re-checks printer_manager.is_connected(printer_id) — only fires the notification if the printer is still offline. Why 60s debounce: sized against bambu_mqtt.py::STALE_RECONNECT_COOLDOWN = 30s — a single stale-trigger + reconnect cycle isn't enough to fire, only a real outage that survives one full cooldown notifies. Transient MQTT blips (WiFi roam, broker reload, brief packet loss) recover within the window and the cancellation path kicks in. Edge-case handling: initial observation with no prior connected state doesn't fire (covers Bambuddy startup with an already-offline printer); a False → False repeat doesn't reschedule (the in-flight task stays in place rather than resetting the clock on every status callback, which would otherwise mean the notification never fires); the task entry pops from _printer_offline_notify_tasks in the finally block whether the notification fired, the printer reconnected, or the task was cancelled mid-await. No symmetric on_printer_online event: the reporter explicitly noted the "printer lost power and interrupted the print" notification already fires when power is restored — that's the print-failure notification, triggered by the firmware reporting gcode_state=FAILED for the interrupted print on MQTT reconnect. That covers the "printer is back" channel without a new toggle. If the user then resumes the print, no print_start notification fires (Bambuddy's bambu_mqtt.py:3039 explicitly suppresses is_new_print for PAUSE → RUNNING to prevent duplicates when resuming from pause), but that's a separate scope from offline-detection. Tests: 9 new cases in test_printer_offline_notification.py split across two classes. TestMaybeNotifyPrinterOffline pins the debounced helper: fires notification when still offline at end of window, doesn't fire when printer reconnected during debounce, doesn't fire when the printer disappeared from the DB (uninstall mid-window), clears _printer_offline_notify_tasks[printer_id] after run. TestOfflineEdgeDetection pins the edge logic inside on_printer_status_change: first observation (connected) doesn't schedule, first observation (disconnected) doesn't schedule (the no-prior-True case — important for startup), True → False schedules a task, reconnect cancels the pending task, repeated False observations don't replace the in-flight task. Full backend suite still green; ruff clean.

Don't miss a new bambuddy release

NewReleases is sending notifications on new releases.