diff --git a/BACKERS.md b/BACKERS.md
index 71a189da7..6509c43a3 100644
--- a/BACKERS.md
+++ b/BACKERS.md
@@ -50,6 +50,8 @@ If you sponsor and your name isn't here within 48h, please write an email to mar
- [@andyspinball](https://github.com/andyspinball
- [@avandeputte](https://github.com/avandeputte)
- [@joeferrante](https://github.com/joeferrante)
+- [@GPop61](https://github.com)
+- [@CooleyMcCoolson](https://github.com/CooleyMcCoolson)
---
## One-time and historical supporters
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e7d37800..0c8ae6f18 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,152 +2,60 @@
All notable changes to Bambuddy will be documented in this file.
-## [0.2.4.7] - 2026-06-14
-
-### Fixed
-- **Assign-spool picker note now visible on mobile (#793 follow-up, reporter @EmcetPL)** — The original fix for #793 added the spool note as an HTML `title=` tooltip on each picker button in `AssignSpoolModal.tsx` (lines 417 + 492). `title=` only surfaces on hover, which doesn't exist on touch devices — a phone user tapping a card just selects it, the note never appears. Users who store their tracking ID in the note field were blind on mobile. **Fix.** Render the note as a small muted truncated line directly under the weight on both the internal-inventory branch and the Spoolman branch: `text-[10px] text-bambu-gray/70 mt-1 truncate`, kept inside the `truthy &&` guard so empty notes don't add a blank row. The existing `title={spool.note}` is preserved on the new `
` element so desktop hover and mobile-browser long-press still surface the full untruncated text for notes that overflow the truncate. Keeps the 2-col mobile grid density unchanged (one extra `text-[10px]` line is ~12 px), no new state, no popover/modal, no new touch target. Mirrored across both branches per the inventory-parity rule so internal and Spoolman pickers stay shape-equal. Frontend `npm run build` clean. `npx vitest run AssignSpoolModal.test.tsx AssignToAmsModal.test.tsx` 23/23 green. **Scope.** No backend change. No new permission. No new i18n key (the note text is user-authored, not translatable).
-
-- **API keys with Manage Library permission can now rename / delete / move library files (#1832, reporter @MorganMLGman)** — `require_ownership_permission` gates API keys on `all_perm` only (line 1668) — the comment block at line 1659 says OWN and ALL "both map to the same scope flag" for queue / archives / etc., so checking `all_perm` is the correct gate. Library deliberately broke that invariant by putting `LIBRARY_UPDATE_ALL` / `LIBRARY_DELETE_ALL` in `_APIKEY_DENIED_PERMISSIONS` while only the OWN variants were allowlisted under `can_manage_library`. Net effect: every library curation route (DELETE `/library/files/{id}`, PUT `/library/files/{id}` rename, POST `/library/files/move`) returned `403 "API keys cannot be used for administrative operations"` for keys with `can_manage_library=True`, contradicting the wiki docs that explicitly list "rename and delete your own library entries" under that scope. Only `POST /library/files/{id}/slice` worked (it doesn't go through `require_ownership_permission`). The "ALL stays admin-only because it crosses the user boundary" comment was internally inconsistent: API keys have no per-row ownership identity (`user=None`), so the route's `file.created_by_id != user.id` ownership check would `AttributeError` on a key acting under OWN anyway — the only path that ever worked was `can_modify_all=True`, which `all_perm` denial blocked outright. **Fix.** Fold `LIBRARY_UPDATE_ALL` and `LIBRARY_DELETE_ALL` into `_APIKEY_SCOPE_BY_PERMISSION` mapping to `can_manage_library` (matching the `can_queue` precedent — both `QUEUE_UPDATE_OWN` and `QUEUE_UPDATE_ALL` map to `can_queue` for the same per-key-identity reason). Remove both from `_APIKEY_DENIED_PERMISSIONS`. `LIBRARY_PURGE` deliberately stays denied — it bypasses the soft-delete window and is genuinely destructive, the kind of cross-boundary op the denylist exists for. **Tests.** 5 new cases in `TestLibraryPermissions` pinning the route-level contract — `test_apikey_with_manage_library_can_delete_file`, `test_apikey_with_manage_library_can_rename_file`, `test_apikey_with_manage_library_can_move_file`, `test_apikey_without_manage_library_still_blocked` (regression guard that the fix widens the allowed-permission set, not the per-key scope check), and `test_apikey_with_manage_library_still_cannot_purge` (LIBRARY_PURGE stays admin-only). The matrix drift-detection in `test_auth_apikey_rbac.py` updated to include `LIBRARY_UPDATE_OWN`, `LIBRARY_UPDATE_ALL`, `LIBRARY_DELETE_ALL` under `can_manage_library` and removes `LIBRARY_DELETE_ALL` from `_ADMIN_CASES`. `pytest -n 30 backend/tests/unit backend/tests/integration` green (6494). Ruff clean. **Scope.** No DB migration. No schema change. No frontend change. The wiki entry for API key permissions at `/features/api-keys/#available-permissions` now matches actual behaviour.
-
-- **HMS Action buttons now reach the printer (#1830, H2D/H2C wrong-plate verification)** — The HMS Actions feature shipped in #1743 looked correct at the publish layer but the firmware silently dropped the commands at the printer, so clicking "Stop printing", "Problem solved and resume", or "Ignore and resume" did nothing visible on the live H2D — the modal kept reappearing, the print stayed paused, and the route still returned `200 OK`. Three independent bugs combined into one user-facing failure. **(1) Wrong command shape for resume / stop.** `hms_resume()` and `hms_stop()` sent the documented-but-not-actually-used `{"err": , "param": "reserve", "job_id": , ...}` shape that BambuStudio never produces. Bambu firmware rejects this silently — verified by injecting candidate shapes on `device//request` against a live H2D paused on a wrong-plate HMS: the `err`-bearing shape held PAUSE → PAUSE for the full window, the plain `{"print":{"command":"stop","param":"","sequence_id":"0"}}` transitioned PAUSE → FAILED in 1.7s, the same plain `resume` transitioned PAUSE → RUNNING in <2s. Fix: both helpers send the plain shape now, no `err`, no `job_id`, no `param:"reserve"`. **(2) `IGNORE_RESUME` mapped to the wrong command for paused prints.** The original mapping dispatched `idle_ignore` for both `IGNORE_RESUME` and `NO_REMINDER_NEXT_TIME`. `idle_ignore` is BambuStudio's "dismiss this warning" command and only works for non-pause warnings — verified against the H2D, idle_ignore on a paused print is silently rejected regardless of `err`. `hms_ignore()` now branches on `self.state.gcode_state == "PAUSE"`: paused → dispatch plain `resume` (which is what the button actually means on a paused print), running/idle → keep `idle_ignore` with the `type=0/1` persistence flag. `DONT_REMIND_NEXT_TIME` on PAUSE degrades to resume too — the "don't remind" flag can't ride along on a resume but the user's clicked-action intent (continue printing) is honoured. **(3) 64-bit `hms[]`-array faults truncated to a non-matching `err` (#1830 §(1)).** The hms[] parser at line 2740 built the short code as `f"{(attr >> 16) & 0xFFFF:04X}_{code & 0xFFFF:04X}"`, discarding 32 of the 64 bits of the fault identifier. For codes whose full form is e.g. `0C00_0300_0002_000C`, the truncated `0C00000C` doesn't match what the firmware compares against in `idle_ignore`. New `HMSError.full_code` field carries the canonical hex identifier — 16 chars `f"{attr:08X}{code:08X}"` for hms[]-sourced faults, 8 chars `f"{print_error:08X}"` for print_error-sourced faults (which are already 32-bit). Catalog lookup tries the 16-char form first and falls back to the 8-char short code so existing entries keep matching. Frontend echoes `error.full_code` back as `HmsActionBody.print_error` instead of recomputing the short code; the schema's pattern relaxes to `^[0-9A-Fa-f]{8}([0-9A-Fa-f]{8})?$` to accept both lengths. **(4) Masking failure — publish-success returned as printer-ack (#1830 §(3)).** `execute_hms_action` returned True the moment the publish succeeded, so any of the three bugs above produced `200 OK` while the printer ignored the command and the modal kept popping. The `/hms/execute-action` route now snapshots `(gcode_state, print_error, hms_errors count)` before dispatch, awaits `HMS_ACTION_ACK_WAIT_SECONDS` (default 2.5s, module-level so tests override), and returns `502 "Printer did not acknowledge HMS action within 2.5s"` if none of those moved. Every accepted HMS action mutates at least one of the three, so this is a clean signal. **Empirical verification.** A test harness on `device/0948BB540200427/request` confirmed each shape against the live H2D: a print sent with deliberately-wrong build plate raises `print_error=0x05008051` ("Detected build plate is not the same as the Gcode file"), the printer enters `gcode_state=PAUSE`, and the new command shapes transition out correctly. The current Bambuddy code (before this fix) failed to act on every button. **Tests.** `test_hms_actions.py` shape assertions rewritten — `test_resume_is_plain_no_err_no_job_id`, `test_stop_is_plain_no_err_no_job_id`, `test_ignore_resume_dispatches_resume_when_print_paused`, `test_ignore_resume_uses_idle_ignore_when_not_paused`, `test_dont_remind_dispatches_resume_when_paused`, `test_dont_remind_uses_idle_ignore_type_one_when_not_paused`, `test_idle_ignore_accepts_16_char_full_code`. New `TestHMSFullCode` class in `test_bambu_mqtt.py` pins the parser contract — `test_hms_array_path_populates_16_char_full_code`, `test_print_error_path_populates_8_char_full_code`, `test_hms_array_catalog_lookup_tries_16_char_first`, `test_hms_array_catalog_falls_back_to_8_char`. New integration cases in `test_printers_api.py` — `test_execute_hms_action_no_printer_ack_returns_502`, `test_execute_hms_action_accepts_16_char_full_code`. The malformed-input test now covers 9- and 15-char rejections (the relaxed pattern accepts 8 OR 16, nothing in between). `pytest -n 30 backend/tests/unit/services/test_hms_actions.py backend/tests/unit/services/test_bambu_mqtt.py backend/tests/unit/services/test_printer_manager.py backend/tests/integration/test_printers_api.py` green (509 + 181). `ruff check` clean. Frontend `npm run build` clean. **Scope.** No DB migration. No new permission. No new i18n key — the frontend toast on action failure already uses the existing `hmsErrors.actionFailed` string, which now gets the more accurate "Printer did not acknowledge" message instead of "Failed to send action". The `HMSError.full_code` field defaults to `""` so old in-memory state surviving a backend upgrade (without an MQTT reconnect) degrades to the existing 8-char short code via the frontend's `||` fallback.
+## [0.2.4.8] - 2026-06-28
### Added
-- **Sponsor-prompt thresholds lowered to fire for typical new installs** — The in-app sponsor toast in `useSponsorPrompt` was calibrated for power users: the lowest print milestone was `100`, the lowest archive milestone was `50`, the lowest filament-cost milestone was `100`. A check of recent Matomo data showed the toast firing very rarely (`?from=app-toast-prints-100` = 4 visits, `?from=app-toast-archives-50` = 3 visits in a 7-day window) — most installs simply never reach those bars, especially with the install base ~doubling since March. Calibration widened: `PRINT_MILESTONES` now `(10, 25, 100, 500, 1000, 2500, 5000)`, `ARCHIVE_MILESTONES` now `(5, 10, 50, 250, 1000)`, `COST_MILESTONES` now `(25, 50, 100, 500, 1000)`. The existing priority order (anniversary → prints → archives → cost → version-update) and 14-day cross-family cooldown are unchanged, so a user still sees at most one toast per fortnight. The "fire highest unseen milestone" logic in `_check_prints` / `_check_archives` / `_check_cost` is unchanged — a user already at 200 prints still gets `prints-100` first (they crossed it earlier in the timeline). The existing toast copy uses `{count}` / `{total}` interpolation in all 11 locales — no new i18n keys needed; "You've completed 10 prints with Bambuddy" reads as fluently as the 100 variant. **Tests.** `test_failed_prints_dont_count` and `test_fires_when_cost_sum_crosses_100` rebalanced (5 completed prints instead of 50; 5 prints × 21 cost-each instead of 30 × 3.5) so they still test "below the lowest threshold" semantics with the new lower bars. New `test_fires_at_lowest_threshold` pins `prints-10` as the new minimum trigger. `pytest -n 30 backend/tests/unit/test_sponsor_prompt_service.py backend/tests/integration/test_sponsor_prompt_api.py` green (25/25). `ruff check` clean. **Scope.** No DB migration. No new permission. No frontend change. The change is opt-in by virtue of the existing toast cooldown — installs that already saw a recent toast see no behaviour change; installs that never crossed the old 100-print bar become eligible the first time they pass 10 prints (subject to the 14-day cooldown after any other family fires first).
+- **Lower sponsor-prompt thresholds so the toast fires for typical new installs** (`257b9e2c`)
+- **SSO autologin + disable local username/password login (#1589, requested by @einstux)** (`549d3216`)
+- **"Auto-add unknown RFID spools" toggle + global confirmation modal (#1764)** (`9f9c1775`)
+- **Backup-aware filament deficit check, colour-strict (#1762)** (`29a5abd9`)
+- **Drag-reorder for grouped queue items; collapsed batches no longer block adjacent rows** (`7b06ebd7`)
+- **Per-filament humidity threshold for auto-drying + alarms (#1605, requested by @thenewguy)** (`7e5eff14`)
+- **Per-printer Maintenance Mode toggle (#1476, requested by @IndividualGhost1905 / Ferdi SEVER)** (`ee270922`)
+- **In-app sponsor-toast at earned milestones** (`e761e092`)
+- **Prominent sponsor banner on Settings → General** (`5c16ef3e`)
+- **Heater history (nozzle / bed / chamber) tracked + per-tile chart-icon overlay opens history modal** (`d1d16659`)
+- **AMS Filament Backup status badge + toggle on the printer card; "Prefer lowest" actually picks the lowest spool (#1766, reported by @biduleman)** (`99c6949b`)
+- **Updated printer card UI for structure and readability (#1661)** (`6fa74be4`)
-- **Autologin via SSO + disable local login (#1589, requested by @einstux)** — Two related additions for operators who run their own OIDC SSO and want exactly one auth path. **Global setting `local_login_enabled`** (default True, preserves pre-#1589 behaviour) — when False, `POST /api/v1/auth/login` rejects username + password credentials with HTTP 401 (same wording as wrong-password to avoid leaking "local disabled" to credential-stuffing tools), `POST /api/v1/auth/forgot-password` rejects with HTTP 403 (the reset wouldn't grant access anyway), and the LoginPage hides the credentials form + Forgot Password link, leaving only the OIDC provider buttons. **Env-var recovery path** `BAMBUDDY_LOCAL_LOGIN=true` (also accepts `1` / `yes`, case-insensitive) bypasses the gate on both routes and flips the reported `local_login_enabled` flag on `/auth/advanced-auth/status` back to True so the LoginPage matches what the route actually accepts — a server admin whose SSO provider is unreachable can recover the install with one env var, no DB editing. LDAP keeps its own `ldap_enabled` switch and is not affected by this gate — a delegated directory has its own policy and lockouts and is closer to SSO than to local credentials. **Per-OIDC-provider `is_autologin` flag** — when set on an enabled provider, the LoginPage redirects unauthenticated visitors directly to that provider's authorize URL on mount instead of rendering the login form. At most one provider can carry the flag at a time (app-layer invariant enforced in both create and update routes: setting it on one provider clears it on every other). **Two-layer fallback for autologin** — the LoginPage races `getOIDCAuthorizeUrl` against a 5-second timeout; on success the browser navigates to the IdP, on timeout or fetch error the redirect is aborted, the page renders normally, and a sticky amber banner explains "Autologin to failed, pick a provider". A bookmarkable `/login?fallback=local` query param always skips the autologin redirect — paired with the `BAMBUDDY_LOCAL_LOGIN=true` env-var on the server, this is the documented "SSO is broken, let me back in" path. **Two safety refusals on disabling local login**: settings PUT returns HTTP 400 ("no OIDC provider is enabled") when no enabled OIDC provider exists, and HTTP 400 ("you would lock yourself out") when the calling admin has no `UserOIDCLink` row. Either failure mode would otherwise lock everyone out of the install. **Backend.** `local_login_enabled: bool = True` added to `AppSettings` + `AppSettingsUpdate` schemas and to the `_BOOL_KEYS` allowlist in `routes/settings.py`. `OIDCProvider.is_autologin: bool` column via `_safe_execute(ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT ...)` — SQLite `DEFAULT 0`, Postgres `DEFAULT false` per the project's existing boolean-migration pattern. New `OIDCProviderResponse.is_autologin` field threaded through `from_attributes=True`. `_local_login_env_bypass()` reads at call time (not import time) so tests can monkeypatch the env between cases. `/auth/advanced-auth/status` extended with `local_login_enabled` and `autologin_provider_id` so the LoginPage decides UI in one query — `autologin_provider_id` filters on `is_enabled=True AND is_autologin=True` so disabling a provider stops the autologin redirect even if the flag stays set. **Frontend.** `LoginPage.tsx` adds the autologin `useEffect` (skips redirect when `?fallback=local` is in the URL, when an OIDC token is already in the fragment, or when an `oidc_error` query param is present from a previous round trip), the autologin-failed banner, and a "Local sign-in disabled" notice that replaces the form when the flag is off. `SettingsPage.tsx` exposes the `local_login_enabled` toggle in the OIDC tab card above the existing provider list; `OIDCProviderSettings.tsx` adds the per-provider Autologin toggle in the form's flags row. `AppSettings`, `AdvancedAuthStatus`, `OIDCProvider`, and `OIDCProviderCreate` TypeScript interfaces extended to match. **i18n.** 6 new keys (`login.autologinFailed`, `login.localDisabledNotice`, `settings.localLogin.disable`, `settings.localLogin.disableHint`, `settings.oidc.form.autologin`, `settings.oidc.form.autologinDesc`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5375 leaves per locale, no English fallback. **Tests.** 6 new integration cases in `test_local_login_gate.py`: login default allows local, login rejected when flag off and no env bypass (with generic 401 wording asserted), env-var bypasses the gate, forgot-password rejected when flag off, status surfaces both new fields, env bypass flips the reported flag back to True. Full nearby suites green: `test_auth_api.py` 44/44, `test_mfa_api.py + test_oidc_relogin.py + test_settings_ui_preferences.py` 159/159. Backend `ruff check` clean. Frontend `npm run build` clean. **Scope.** No new permission — the existing `SETTINGS_UPDATE` permission gates the toggle. The migration is a single `ADD COLUMN` per backend; the `local_login_enabled` setting lives in the existing settings key-value table and needs no migration. Default behaviour is unchanged: fresh installs and upgrades see no difference until an admin explicitly enables the toggle or sets a provider as autologin.
+### Changed
+- **Printer card AMS row: external tray height matches regular AMS slots** (`00e4aed7`)
-- **Printer card AMS row: external tray height matches regular AMS slots** — On dual-nozzle printers (H2C / H2D) the External card carried an extra `Ext-L` / `Ext-R` caption underneath each tray to disambiguate which extruder it fed. That caption added one text line of vertical height to the External card only, so the entire bottom row of the printer card's AMS panel (External alongside AMS-C / HT-A) was visibly taller than the row above it (AMS-A / AMS-B). Fix: the L/R distinction now lives **inside** the slot's colour circle in place of the 1-based slot index (so the left external tray reads `L`, the right reads `R`), and the bottom caption is removed. Single-nozzle externals — a single tray with no left/right distinction — keep the `1` index. The `FilamentSlotCircle` `slotNumber` prop is widened from `number` to `number | string` to carry the L/R label; the two regular-AMS callsites that pass a numeric index keep working unchanged. The `Ext-L` / `Ext-R` strings are still used as the slot's "location" label in the filament hover card (so context is preserved when hovering for details) — just not as a separate caption on the visible row. Frontend `npm run build` clean. Existing 10 `FilamentSlotCircle` tests stay green (the new optional string accept-shape is backward-compatible).
-
-- **Cam Wall: don't kill shared streams when one viewer closes + offline tiles show OFF, not LIVE** — Two small but load-bearing fixes against the new cam-wall view. **(1) Offline tile chip.** A disconnected printer (`status.connected === false`) was still assigned `live` mode by `CameraWall.modeByPrinter` — it consumed a `Max live streams` budget slot AND rendered the red `LIVE` chip on top of the `WifiOff` placeholder. The allocator now treats `!connected` like off-screen — assigns `paused`, leaves the live budget intact. The existing `CameraTile` rendering (`WifiOff` icon, dark `Off` chip) takes over automatically. Side effect: an 8-printer wall with 2 offline X1Cs no longer wastes 2 of the 4 default live slots on dead tiles. **(2) Shared-broadcaster teardown.** `/api/v1/printers/{id}/camera/stop` is the unmount cleanup for every camera consumer (`CameraTile`, `EmbeddedCameraViewer`, popup `CameraPage`). It used to unconditionally `shutdown_broadcaster(f"printer-{id}")` + kill every ffmpeg in `_active_streams` whose key starts with `{printer_id}-`. The fan-out broadcaster is shared across all viewers of the same printer, so closing the embedded viewer while the cam-wall tile of the same printer was visible force-killed the source the tile was pulling from — the tile's ` ` errored out and showed `No signal` until the user navigated away. The broadcaster itself already has correct natural-shutdown semantics: each subscriber's HTTP teardown calls `unsubscribe(queue)`, and when the count reaches 0 the broadcaster's own `_grace_then_stop` waits `_GRACE_SECONDS` (5 s) before tearing down — re-checking under the lock so a new subscriber rejoining cancels the shutdown. `/camera/stop` was just a fast-cleanup shortcut for the single-viewer case. **Fix.** New `get_subscriber_count(key)` accessor in `camera_fanout.py` exposes the broadcaster's `subscriber_count` (the private list-len already used internally). The `/camera/stop` route now reads `get_subscriber_count(f"printer-{printer_id}")` BEFORE the force-teardown; when ≥ 1 subscriber is still attached, it returns `{"stopped": 0, "skipped": true}` early and leaves the broadcaster + ffmpeg processes alone. The leaving viewer's HTTP teardown still runs the natural `iter_subscriber.finally → unsubscribe` path, so its subscription is correctly released; the broadcaster keeps serving the other viewer(s). Single-viewer close still hits the force-teardown path immediately (no subscribers remain at all). Cost: in the race where the leaving viewer's HTTP teardown has already propagated to the broadcaster at the moment its `/camera/stop` POST lands (count just dropped to 0), force-teardown still runs and we miss the optimization for a different actually-still-subscribed viewer — but the natural grace-shutdown bounds the worst case at 5 s of ffmpeg tail, not a stuck stream. Verified by inspection: this race only matters when subscriber_count transitions through 0 between the HTTP teardown and the POST, which requires both viewers' tabs to close in lockstep — practically unobservable. **Tests.** New `test_stop_camera_stream_skips_shutdown_when_subscribers_remain` in `test_camera_api.py` patches `get_subscriber_count` to return 2 and asserts `/camera/stop` returns `{stopped: 0, skipped: true}`, does NOT call `shutdown_broadcaster`, and does NOT terminate any `_active_streams` ffmpeg process. The existing 6 stop-route tests stay green because they don't pre-populate subscribers — `get_subscriber_count` returns 0, the early-return doesn't trigger, and the existing force-teardown still runs. Full `test_camera_api.py` 43/43 green. `ruff check backend/` clean. Frontend `npm run build` clean. **Scope.** No API contract change — the existing `{"stopped": int}` shape is preserved, the new `"skipped"` field is additive. No new permission. No DB migration. No i18n change.
-
-- **Cam Wall: per-tile print/printer status overlay** — Cam-wall tiles now surface live printer state on top of the camera image instead of being a pure video grid. A new gear-menu toggle `Status overlay` switches between `Off`, `Compact`, and `Full` (default `Full`). **Compact** paints a colour-coded state chip in the top-left corner — `Printing` / `Paused` / `Finished` / `Error` — bucketed using the same `classifyPrinterStatus` rules that drive the printer-card badges, with `Idle` deliberately suppressed so a wall of cold printers stays visually quiet. **Full** adds a bottom info strip on tiles whose state is `Printing` or `Paused`: the active file's `subtask_name ?? gcode_file`, the rounded progress percent, `Layer N/M` when both are known, and the remaining time formatted by the existing `formatDuration(remaining_time * 60)` helper from `utils/date.ts` — so the numbers match what the printer card shows for the same printer. When the printer's known HMS errors are non-empty (filtered via the existing `filterKnownHMSErrors` from `HMSErrorModal`), the chip flips to the red `Error` colour with a `lucide-react` `AlertTriangle` icon inline. The whole overlay layer is gated by `connected` — disconnected and paused-mode tiles render the existing offline / paused placeholders unchanged. **Zero new network cost.** `CameraWall.tsx` already ran `useQueries({ queryKey: ['printerStatus', id], ... })` against every printer for the connected flag; the patch widens the `useMemo` to expose the full `PrinterStatus` payload and threads `state`, `progress`, `remaining_time`, `layer_num`, `total_layers`, `subtask_name`, `gcode_file`, and the filtered HMS error count into each `CameraTile` — same shared React Query cache the `PrinterCard` flow populates, so Cards ↔ Cam Wall flips remain instant and the wall opens no second status fan-out. **Settings.** Per-user, persisted in `localStorage` under `camWallStatusMode` alongside the existing `camWallMaxLive` and `camWallSnapshotSec` keys. The picker is a three-segment button row inside the existing cam-wall settings popover (gear icon, click-outside dismiss), labelled `Off` / `Compact` / `Full`. Default `Full` because the cards already show this info — users who pick cam-wall view still want to glance the same details without flipping back. **CameraTile contract.** All new props (`statusMode`, `printerState`, `progress`, `remainingMin`, `layerNum`, `totalLayers`, `printName`, `hmsErrorCount`) are optional with safe defaults, so the 5 existing vitest cases in `CameraTile.test.tsx` continue to pass unmodified — the status layer is purely additive on the leaf component. The state-bucket classifier lives co-located in `CameraTile.tsx` (mirrors `PrintersPage.classifyPrinterStatus` for `RUNNING/PAUSE/FINISH/FAILED`) so the tile renders correctly even if called outside the cam-wall scheduler. **Temperatures intentionally not surfaced.** Nozzle / bed / chamber readouts would crowd the tile and overlap the existing top-right LIVE/SNAP/OFF mode indicator and bottom-edge printer name; the printer card remains the canonical surface for those. **i18n.** 7 new keys under `printers.camWall` (`layer`, `timeLeft`, `statusMode.{off,compact,full}`, `settings.statusOverlay`, `settings.statusOverlayHint`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) — no English fallback. State chip labels reuse the existing `printers.status.{printing,paused,finished,error,idle}` keys so no new translation work was needed for the bucket vocabulary. Parity script `check-i18n-parity.mjs` adds two legitimate-cognate exceptions: `Compact` for French (same word) and `Off` for Italian (universal loanword); both remain real translations in every other locale. Parity check 5388 leaves per locale. **Scope.** No backend change. No new request. No new permission. No DB migration. The toggle defaults to `Full`, so installs see the overlay the first time they open Cam Wall — flipping to `Off` reverts to the original camera-only behaviour.
-
-- **Cam Wall view on the Printers page** — New view toggle next to the card-size selector flips the entire printers list into a responsive grid of live camera tiles (`Cards` ↔ `Cam wall`). Reuses the existing per-printer FTP / RTSPS proxy on `/api/v1/printers/{id}/camera/stream`, so the backend ffmpeg fan-out is the same one EmbeddedCameraViewer already drives — no new server-side state machine. Bandwidth ceiling matters on the RPi installs ([[bambuddy-install-base-2026-06-20]] documents that the median deployment is a Pi 4): each live tile is one TLS pull + one MJPEG fan-out. To stay sustainable on a Pi 4 with 8+ printers, only the tiles currently on-screen are live, and only up to `Max live streams` (default 4) at any moment — everything else falls back to per-tile snapshot polling against `/api/v1/printers/{id}/camera/snapshot` at a configurable interval (default 8 s). Tiles that scroll off-screen pause entirely. **Architecture.** `frontend/src/components/CameraTile.tsx` is the leaf — three modes (`live` / `snapshot` / `paused`), a single ` ` element with `loading="lazy"`, an `onError` no-signal fallback, and a `useEffect` cleanup that POSTs `/camera/stop` (with `keepalive: true`) on mode-out-of-live AND on unmount so the backend releases the transcoder slot. Same `/camera/stop` discipline EmbeddedCameraViewer uses, so a tile that scrolls off the wall is byte-identical to closing a floating viewer. `frontend/src/components/CameraWall.tsx` is the scheduler — an `IntersectionObserver` (threshold 0.4 to avoid flicker at scroll boundaries) tracks visibility, then a `useMemo` walks the printer list in sort order and assigns the first N visible tiles to `live`, the rest of the visible set to `snapshot`, and off-screen tiles to `paused`. The walker is stable on a given render (no LRU eviction churn) which avoids the "tile flickers between live and snapshot every frame" failure mode. Reuses the same `['printerStatus', id]` React Query cache each `PrinterCard` already populates, so flipping between Cards and Cam Wall is instant and the wall doesn't open a second status fetch fan-out. Clicking a tile honours the existing `Settings → camera_view_mode` preference — opens the floating `EmbeddedCameraViewer` when set to `embedded`, otherwise pops the `/camera/:id` window with the saved size/position from `cameraWindowState`. **Settings.** Both knobs are per-user, persisted in `localStorage` (`camWallMaxLive`, `camWallSnapshotSec`) — not a global backend setting, since a Pi 4 user and a NUC user looking at the same install want different caps. Bounded `[1, 16]` for max live and `[2, 60]` seconds for snapshot interval, both rendered as an inline gear-icon popover above the grid with click-outside dismiss. The Cam Wall button is permission-gated on `camera:view`; viewers without the permission see it disabled. The card-size selector goes opacity-40 + pointer-events-none in cam-wall mode (tile size is governed by the responsive grid, not the cardSize knob). **i18n.** 13 new keys (`printers.pageView.cards`, `printers.pageView.camWall`, `printers.camWall.{noPrinters,noSignal,live,snap,off,summary}`, `printers.camWall.settings.{title,maxLive,maxLiveHint,snapshotInterval,snapshotIntervalHint}`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) — no English fallback. Parity check 5369 leaves per locale. **Tests.** 5 new vitest cases in `frontend/src/__tests__/components/CameraTile.test.tsx` cover live URL emission with `fps=8`, snapshot URL emission with the cache-bust counter advancing on the interval, offline placeholder for disconnected printers, paused placeholder rendering, and the `/camera/stop` POST firing when the tile transitions out of live. **Scope.** No backend change. No DB migration. No new permission. The existing `EmbeddedCameraViewer` is untouched — Cam Wall is purely additive. The `printerPageView` toggle defaults to `cards`, so installs see no behaviour change until a user picks Cam Wall.
-
-- **AMS drying badge now shows the active cycle's filament + target temperature** — During an active drying cycle the AMS card on the printers page renders `Drying · PETG @ 65°C · 11h 35m left` (the loaded-filament line under the slots) instead of the bare `Drying · 11h 35m left`. Bambu's per-tick AMS push only carries the `dry_time` countdown — the chosen filament name and target temperature are never echoed on the wire, so the badge had no source of truth for them. `BambuMQTTClient.send_drying_command(mode=1, ...)` now caches `{ams_id: {filament, temp}}` on the client; the cache is cleared on `mode=0` and on the per-AMS `dry_time` falling-edge to 0 (same detector that drives the smart-plug-after-drying callback). `PrinterManager.get_drying_targets(printer_id)` exposes it, `printer_state_to_dict` and `routes/printers.py::get_printer_status` thread it onto each AMS dict as `dry_target_temp` + `dry_filament`, the AMS schema gains both fields, and the AMS-HT compact badge gets the same render. Falls back to the first loaded tray's `tray_type` + RFID-recommended `drying_temp` when no cached target (drying started before backend launch, backend restarted mid-cycle, or cycle started from another source) — the same heuristic the popover already uses to seed defaults. New i18n key `printers.drying.targetSummary` = `{{filament}} @ {{temp}}°C`, translated in all 11 locales (parity check 5356 leaves per locale). 5 new backend tests in `TestSupportsDryingCommand` (cache populated on mode=1, overwrite on second start, cleared on mode=0, per-AMS isolation across stop) and 4 new tests in `TestDryingTargetExposure` (cached target wins over fallback, fallback derives from loaded tray, both fields None when no cache + empty trays, targets don't leak across AMS ids). **Note about Bambu's printer display.** A user reported that with PLA loaded in AMS-A slot 1 and a Bambuddy-initiated PETG @ 65°C drying cycle, the H2D's own screen showed "PLA" — Bambuddy's wire payload was confirmed correct via journalctl (`filament: "PETG"` sent, `result: success, filament: PETG, temp: 65` ACKed back). The display behaviour is the Bambu firmware labelling the active cycle by the loaded tray's filament rather than the `filament` field of the command. This Bambuddy change makes our own UI reflect what we actually sent, independent of the firmware's display choice.
-
-- **Continue auto-drying while a print is running on capable hardware** — Bambu shipped "Print While Drying" firmware-side on H2D (01.03.00.00+), H2C / H2S / P2S / H2D Pro (01.02.00.00+), X2D / A2L (01.01.00.00+), and X1C (01.11.02.00+). The existing Queue Auto-Drying loop only fires on idle printers — when a print starts, drying stops or never starts, even though the spools may still be wet. New **Settings → Print Queue → "Continue drying while printing"** toggle (default OFF) lets the same scheduler evaluator also run on the *busy* printer set. Backend: `supports_drying_while_printing(model, firmware)` in `printer_manager.py` is a strict allowlist verified against Bambu's wiki release-notes phrasing ("printing while filament is drying" / "Print While Drying" — every matrix-confirmed model carries that wording verbatim; **P1P / P1S / A1 / A1 Mini / X1 (non-C) / X1E are intentionally excluded** because the wiki is silent for them, and on those models the firmware would reject the command anyway via `dry_sf_reason=[0]` (TaskOccupied)). The capability is gated on both display names (`"H2D"`, `"X1C"`, ...) and internal SSDP / MQTT model codes (`"O1D"`, `"O1E"`, `"O2D"`, `"O1C"`, `"O1C2"`, `"O1S"`, `"N6"`, `"BL-P001"`, `"N7"`, `"N9"`) — the printer's `model` field can carry either, the existing `supports_drying` precedent uses both. `_check_auto_drying` in `print_scheduler.py` now resolves model + firmware up front for every printer and computes `mid_print = busy AND toggle_on AND supports_drying_while_printing`; when `mid_print` is True the busy-skip, queue-only-skip, and idle-skip gates are bypassed and the existing humidity / `dry_sf_reason` / drying-presets / mode-1 send path takes over. **Safety: drying temp is capped at `max(40, preset_temp - 5)` for mid-print drying** — Bambu's own release notes for H2D and P2S spell out "Lower drying temperature during printing" / "The drying temperature must not exceed the filament's softening temperature", so a 5 degC offset from the idle preset (floor 40) protects spools inside a hot enclosure during an active print. The early-return guard that short-circuits the evaluator when "only queue mode is on AND nothing scheduled" was also extended to skip the short-circuit when `print_drying_enabled` is on — otherwise busy printers would never be reached. The manual drying button on the AMS card needs no UI change: `routes/printers.py::start_drying` has no Bambuddy-side `is_idle` gate; the "printer busy" rejection comes from firmware `dry_sf_reason=[0]`, which simply won't appear on supported firmware mid-print. The new capability flag is also surfaced on `PrinterStatus.supports_drying_while_printing` so the frontend can light up the AMS card affordances correctly. **Settings.** New `print_drying_enabled: bool = False` in `schemas/settings.py`, added to the boolean allowlist in `routes/settings.py` (`_BOOL_KEYS`), and threaded through the existing dirty-detection / save call in `SettingsPage.tsx`. **i18n.** 2 new keys (`settings.printDryingEnabled`, `settings.printDryingEnabledDescription`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5354 leaves per locale, no English fallback. **Tests.** 7 new cases in `TestSupportsDryingWhilePrinting` cover every supported display name + internal code, below-min firmware, excluded models (`P1*`, `A1`, `A1 MINI`, `X1`, `X1E`), missing firmware, `None` model, case-insensitivity, and the strict unknown-model default (False — unlike `supports_drying` which leniently allows unknowns). 4 new scheduler integration cases in `TestMidPrintDrying` cover: toggle ON + capable hardware fires drying at the 40 degC cap for PLA, PETG caps to 60, toggle OFF still skips busy printers, and toggle ON with too-old firmware / excluded model still skips. Full `pytest -n 30` green (4251/4251 in 49 s). Backend `ruff` clean. Frontend `npm run build` clean. **Scope.** No DB migration. No new permission. The new toggle is opt-in (default OFF) — existing installs see no behaviour change until a user enables it, and the firmware is the ultimate arbiter via `dry_sf_reason` so being too permissive here costs nothing.
-
-- **Batch / mass edit on the Filament tab (#1795, requested by @RoBoT24-web)** — Bulk operations land on the Inventory page in both built-in and Spoolman modes. Reporter wanted "ten of the same spool, set a pressure advance value, save once" — the existing flow forced ten round-trips through the per-spool editor. **Frontend.** A new checkbox column anchors the leftmost slot of every row in the table view (header checkbox toggles every visible row; group rows expose a single checkbox that selects every member). As soon as one row is selected, a sticky toolbar appears above the list with **Edit / Print labels / Reset usage / Archive (or Restore in the Archived tab) / Delete / Clear selection**. The selection clears automatically on any filter or tab change so the toolbar count can never drift from what's on screen. A new `BulkEditSpoolsModal` is the entry point for the bulk-edit action: a three-state-per-field form (untouched / set-to-value) over the flat spool attributes — material, subtype, brand, color name + RGBA, storage location, slicer filament name + ID, cost / kg, note, label weight, core weight, category, low-stock threshold %. The reporter's pressure-advance use case (K-profile) stays per-spool because K-profiles are scoped per `(printer, extruder, nozzle_diameter)` and bulk-applying a single K-value across heterogeneous printers would create wrong calibration — they're handled in the existing per-spool K-profile editor instead. **Clearing fields in bulk is intentionally NOT supported** (user decision on #1795): bulk-set lets you only WRITE non-empty values; emptying ten notes by mistake is a one-click disaster the dialog doesn't expose. The per-spool editor remains the path for clearing. **Same dropdown controls the per-spool editor uses.** Material, sub-type, brand, category, slicer preset name, and slicer filament are all rendered through a new `SearchableSelect` component matching the per-spool form's pattern (text input + chevron + filtered list of buttons, click-outside + Escape close). No native `` anywhere in the modal. Material / sub-type / brand options merge the canonical `MATERIALS` / `KNOWN_VARIANTS` / `DEFAULT_BRANDS` constants from `spool-form/constants.ts` with whatever's already in inventory. Slicer-preset dropdowns fetch the same sources as the per-spool form (Bambu Cloud presets when signed in, Orca Cloud profiles, local presets, built-in filaments) via three `useQuery` calls gated on `isOpen` so closed modal pays no fetch cost; results pipe through the shared `buildFilamentOptions(...)` helper so the option list is byte-identical to what the per-spool editor shows. Storage location is a `searchableClosed` SearchableSelect over actual `api.getLocations()` rows mapped to `location_id` (the FK), matching the per-spool form's behaviour (rather than the legacy free-text `storage_location` column, which would have written to a different column than the per-spool editor). **Backend.** Four new endpoints per inventory mode, eight total: `POST /api/v1/inventory/spools/bulk-update`, `bulk-delete`, `bulk-archive`, `bulk-restore` (built-in) and the matching `/api/v1/spoolman/inventory/spools/bulk-*` (Spoolman). All gated on the existing `INVENTORY_UPDATE` / `FILAMENTS_UPDATE` permissions used by the per-spool routes. The built-in update endpoint runs the same `prepare_internal_spool_payload(...)` path as the per-spool PATCH (location resolution, weight-lock auto-stamp on explicit `weight_used` — both inherited identically). The Spoolman update endpoint loops the existing per-spool `update_spool` route function so the complex filament re-linking / extra-dict / extra-lock / shared-filament-detection rules stay byte-identical to single-spool edits — the bulk route is just a fan-out, not a parallel reimplementation. Per-spool failures inside the loop are collected and returned as `{updated, errors: [{id, status, detail}]}` so one bad ID never aborts the batch. The built-in archive endpoint reports `{archived, already_archived, not_found}` so the UI can distinguish "no-op because already archived" from "missing row." Both modes broadcast a single `inventory_changed` WS event at the end of the batch instead of one per row, so the table refresh is a single re-fetch. **Spoolman bulk-delete / archive / restore now also catch non-HTTPException mid-batch** — earlier these three caught only `HTTPException`; a mid-batch `httpx.ConnectError` / `TimeoutError` / `KeyError` aborted the route with a 500, the loop's accumulated state was lost, and the `inventory_changed` broadcast was skipped so the table didn't refresh past the partial state. `bulk_update_spools` got this right out the gate; the audit pass added the same `except Exception` arm to the other three so a transient Spoolman blip surfaces in the per-row errors array instead of obliterating the whole batch. **All-failed and partial-failure are surfaced to the user.** The first cut of the four `onSuccess` mutation handlers only read the success count, so a response of `{updated: 0, errors: [50 entries]}` (e.g. every selected ID was deleted by another user before the click landed) showed a green "0 spools updated" toast and silently cleared the selection. The handlers now branch on three outcomes — all-succeeded (existing success toast), partial (`{ok, failed}` warning toast), and all-failed (red error toast + selection preserved + modal stays open so the user can retry). Same shape for delete / archive / restore. **`bulkResetConsumedCounterMutation.onSuccess` now closes the confirm modal + clears the selection** — earlier inconsistency with the other three bulk mutations left the confirm dialog open after the action. **Invalid RGBA hex is now flagged inline** instead of being silently dropped from the patch. Typing "RED" or "FF00" in the colour field now paints the input red with helper text and disables the Apply button via a new `hasDroppedTickedField` guard that detects any ticked field whose value gets normalised away — without this guard the user clicked Apply, the rgba was silently omitted, and the success toast still fired for the other fields. **Backend tests.** 17 new integration cases. 10 in `test_inventory_bulk.py` covering update applying to multiple rows, unknown IDs reported in `not_found`, empty update body rejected with 400, weight-lock auto-stamp parity with per-spool PATCH, empty `ids` rejected with 422, bulk delete with mixed valid/invalid IDs, archive setting `archived_at` on multiple rows + skipping already-archived, restore the symmetric inverse. 7 in `test_spoolman_inventory_bulk.py` covering the Spoolman update calling `update_spool_full` once per ID with the same payload, per-spool exception collected without aborting the batch (404 on one ID + 2 successes returns `{updated: 2, errors: [{id, status: 404, ...}]}`), empty update rejected, empty `ids` rejected, bulk delete fan-out, bulk archive calling `set_spool_archived(spool_id, archived=True)` for each ID, bulk restore the inverse. Full `pytest -n 30` green (6384/6384 in 68 s). **Frontend behaviour.** Selection state is per-page-session — leaving the Inventory tab and coming back clears the set, mirroring the existing label-printer scope. The action toolbar collapses into the existing `ConfirmModal` for destructive operations (Delete is `variant: 'danger'`; Archive / Restore / Reset usage are `'warning'`). Errors surface via the existing `useToast`. **API client.** Added `bulkUpdateSpools / bulkDeleteSpools / bulkArchiveSpools / bulkRestoreSpools` and the four `bulkXSpoolmanInventorySpools` equivalents — matches the per-mode pattern already used for `bulkResetSpoolConsumedCounter`. **i18n.** 42 new keys under the new `inventory.bulk.*` namespace (33 toolbar / modal / confirm + 4 partial-failure toasts × 4 actions + invalid-hex inline helper + 1 useCustom autocomplete affordance), translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5345 leaves per locale, no English fallback. **Scope.** No DB migration. No new permission. SQLite + Postgres parity verified — the bulk endpoints use the same model + ORM paths as the per-spool routes. Grid (card) view does NOT get checkboxes in this drop — the reporter explicitly requested the Filament-tab list (table view); adding card checkboxes can ship as a follow-up if asked.
-
-- **"Auto-add unknown RFID spools" toggle + global confirmation modal for unknown spools (requested by @maziggy after a wave of duplicate-inventory reports)** — New setting under Settings → Filament → Filament Tracking. Default is ON (current behaviour preserved); turning it OFF stops Bambuddy from auto-creating an inventory entry when an unknown RFID tag is read by the AMS. Use case: users who manually pre-register new spools on delivery (so the inventory record carries their notes / weight / cost) were getting silent duplicates the first time they loaded each spool — the auto-matcher requires exact material+colour+subtype+brand, and pre-created records rarely match strictly enough. Backend gates the auto-create in BOTH inventory modes (`backend/app/main.py` for the built-in inventory loop and `backend/app/services/spoolman.py::sync_ams_tray` for the Spoolman path; auto-sync + both manual sync routes in `backend/app/api/routes/spoolman.py` thread the flag). When suppressed, the existing `unknown_tag` WS event fires so the frontend can surface the slot. **Confirmation modal.** A new global modal pops up on the next page render whenever an unknown RFID is detected — shows the printer / AMS-X label / slot number, the spool's material + colour swatch, and asks the user whether to add it now ("Add to Inventory" / "Cancel"). Mounted in `Layout.tsx` (inside ``) so the prompt appears regardless of which page the user is on, but never on SpoolBuddy kiosk / login / setup routes. Multiple concurrent unknown spools queue and present one-at-a-time; the frontend won't double-queue the same slot. Backed by two new explicit endpoints: `POST /api/v1/inventory/spools/from-slot` (built-in inventory) and `POST /api/v1/spoolman/spools/from-slot` (Spoolman), gated on `INVENTORY_UPDATE` / `FILAMENTS_UPDATE` respectively. Both look up the slot's current tray data server-side and create + auto-assign the spool atomically. SpoolBuddy frontend is unchanged — its existing `handleQuickAddToInventory` flow already covers the kiosk's separate path. **Backend dedup that prevents nag and survives a failed broadcast.** `_unknown_tag_last_broadcast: dict[printer_id, dict[(ams_id, tray_id), (tag_uid, tray_uuid)]]` in `main.py` ensures the same (slot, tag) pair only broadcasts ONCE per MQTT-push cycle, no matter how often the firmware re-asserts the slot state. The slot's empty-tray-data MQTT push clears that slot's entry, so remove-then-reinsert reliably re-prompts. Successful matches (`get_spool_by_tag`, `find_matching_untagged_spool`, auto-create) also clear the entry so a future tag swap on the same slot re-prompts. The dedup-set runs AFTER `await ws_manager.broadcast(...)` completes, so a crash mid-await doesn't poison the dict and permanently silence the slot (an earlier draft set the dedup before the await — bit on a `NameError` regression during development). **Tray data shipped with the event, not looked up.** The WS payload now carries `tray_type`, `tray_color`, `tray_sub_brands`, and `tray_count` straight from the live MQTT message, so the modal renders the correct material / colour without depending on the React Query `printerStatus` cache (which lagged the WS event by several seconds during the first end-to-end test and showed `PLA / #FF0000` instead of the actual filament). Frontend hook `useUnknownTagPrompt` reads them out of the event detail directly. **Shared `getAmsLabel`.** Moved from `PrintersPage.tsx` (and a near-duplicate in `ConfigureAmsSlotModal.tsx`) to `frontend/src/utils/amsHelpers.ts`. Both consumers now import the shared version; the canonical implementation produces `AMS-A / AMS-B / HT-A / External` rather than the bare `AMS 3` my first draft of the modal emitted. **Spoolman `from-slot` no longer reports success when the slot binding fails.** Earlier in this audit pass a swallowed `try/except Exception: rollback + log` left the route returning `{"success": True}` even when the slot-assignment INSERT was rolled back; the user saw the "Spool added" toast while the modal re-fired on the next MQTT push. Now raises HTTP 500 with the underlying error so the frontend surfaces it. **`AppSettings` TypeScript interface.** Added `spoolman_enabled: boolean`, `auto_add_unknown_rfid: boolean`, and `spoolman_url: string` — the backend has always returned these on `/api/v1/settings/` (verified by `test_settings_api.py:144` which asserts `result["spoolman_enabled"] is True`), but the TS type omitted them and required a runtime cast; now strictly typed. **i18n.** 9 new keys (`settings.autoAddUnknownRfid`, `settings.autoAddUnknownRfidDesc`, `inventory.addToInventory`, `inventory.addToInventoryPending`, `inventory.addToInventorySuccess`, `inventory.addToInventoryFailed`, `inventory.unknownSpoolTitle`, `inventory.unknownSpoolMessage`, `inventory.unknownSpoolSlot`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity check 5301 leaves per locale. **Scope.** No DB migration (setting lives in the existing `settings` key-value table; same shape on SQLite and Postgres). No new permission. Manual `Sync AMS` actions also honour the setting — the skipped-spool result list reports `Auto-add disabled; add to inventory manually` so the user knows the slot was intentionally skipped, not failed. **Verification.** Full backend `pytest -n 30`: 6367/6367 in 79 s. Focused integration suite (settings / Spoolman / slot-assignments / slot-concurrency): 122/122. Frontend `npm run build` clean. Backend `ruff` clean. End-to-end smoke-tested live on an H2D + dual AMS setup: insert → modal pops with the correct `AMS-B Slot 4` + real material / colour; Cancel → no re-prompt; remove + reinsert → modal returns; Add → spool lands in inventory + slot tile shows it.
-
-- **Spoolman weight tracking for no-3MF "Untitled" prints (#1820, requested by @ojimpo)** — Closes a long-standing parity gap between Bambuddy's two inventory modes. When a Bambu print starts that Bambuddy can't fetch a `.gcode.3mf` for — typically an unsaved BambuStudio project, where the printer reports `subtask_name: 名称未設定` ("Untitled") and FTP returns 550 for every candidate path — the existing flow created a fallback archive but Spoolman saw no weight change for that print. The internal-inventory side already handles this via the Path 2 AMS remain%-delta fallback in `usage_tracker.on_print_complete` (line 517). Spoolman now mirrors the same shape. **`store_print_data`** now captures `tray_remain_start` (per-slot `remain%` + `tray_uuid` at print start) on every print — keyed `"-"`, slots with invalid `remain` (e.g. -1, AMS hasn't read the spool yet) silently dropped, VT external trays encoded as `ams_id=255` to match internal — and no longer early-returns when the 3MF is missing: it creates an `ActivePrintSpoolman` row with `filament_usage=None` carrying only the snapshot, so the completion path has something to work with. **`report_usage`** keeps its 3MF path as the primary writer and adds `_report_remain_delta_for_slots` for any slot the 3MF path didn't cover (no-3MF entirely OR partial coverage where slice_info omitted a slot). The fallback resolves each slot to its Spoolman spool via the existing `spoolman_slot_assignments` table, looks up the curated `Filament.weight` from the spool's filament record, and writes `(start_remain - current_remain) × weight / 100` grams via `client.use_spool(...)`. **No `tray_weight` from MQTT** — the failure mode #1119 documented (non-RFID spools have no MQTT `tray_weight`, so remain% × tray_weight gave garbage and silently mis-tracked) is dodged the same way internal inventory dodges it: by reading the user-curated reference weight from the inventory store rather than trusting MQTT's raw field. RFID gate not needed — Spoolman's curated `Filament.weight` is present for RFID and non-RFID spools alike. **Mid-print spool swap detection** — when `tray_uuid` differs between start snapshot and completion read, the slot is skipped rather than mis-attributed. We don't know how much of the print went to which spool; preserving correctness is better than guessing. **Double-charge guard** — slots already written by the 3MF path land in a `handled_global_tray_ids` set that the fallback consults before charging, so a 3MF-covered slot can't also pick up a remain delta. **#1119 invariant preserved** — the deprecated AMS-remain%-based GLOBAL writer is still gone. This is per-slot, per-print, gated on a valid start/current `remain` AND a resolvable Spoolman spool. No new setting, no toggle: the parity rule [[feedback_inventory_modes_parity]] applies — same shape as internal inventory, which is unconditional. **No-op default** — installs with no Spoolman slot assignments, no RFID-readable AMS, or no print-time remain% (printer offline at start, AMS still loading) see no behaviour change. **DB.** `active_print_spoolman` gets a new nullable `tray_remain_start TEXT` column via `_safe_execute(ALTER TABLE … ADD COLUMN)`, and the existing `filament_usage TEXT NOT NULL` is relaxed to nullable — for SQLite via `writable_schema = ON` + `sqlite_master` patch + `schema_version` bump (same surgical pattern used for `users.password_hash` NULL relaxation a few hundred lines below), for Postgres via `ALTER COLUMN … DROP NOT NULL`. SQLite + Postgres parity verified. CREATE TABLE updated to emit the new shape on fresh installs. **Tests.** 11 new unit cases in `test_spoolman_no3mf_remain_fallback.py`: 5 for `_snapshot_tray_remain` (valid remain captured, invalid remain skipped, VT tray encoding, empty raw_data, missing uuid defaulted to ""), 3 for `store_print_data` no-3MF behaviour (row created with snapshot when no 3MF + valid remain; no row when neither 3MF nor remain; 3MF path also captures snapshot for partial-coverage fallback), 3 for `report_usage` remain-delta (writes `(start-end) × Filament.weight / 100` to resolved spool; skips swapped spool when `tray_uuid` changed; skips slots already handled by 3MF). Full `pytest -n 30` green on the Spoolman + tracking + archive + on-print suites (1205/1205). Backend `ruff` clean.
-- **NTP-gate state exposed on the appliance endpoint** — `GET /api/v1/system/appliance` gains a `time_synced` field returning `"ok"`, `"warning"`, or `null`. Source: `/run/bambuddy/time-synced`, written by the appliance's `ntp-gate.sh` once chronyd reports sync (or after a 3-minute timeout with a `"warning"` marker). The RPi 5 has no battery-backed RTC, so on a fresh boot the system clock is wrong until NTP catches up — JWT expiries and TLS certificate validity windows depend on this being right. New `backend/app/core/local_config.py::read_ntp_gate` is defensive on every failure mode (file absent → `None`, OSError → `None` + warning log, empty / unknown content → `None`, binary garbage survives via `errors="replace"`). The endpoint stays no-auth; the SPA can use the field to render a "time not synced" badge on a fresh appliance before swapping to normal status once `"ok"` comes through. 8 new unit cases for `read_ntp_gate` (absent / ok / warning-suffixed / warning-only / empty / unknown-marker / leading-whitespace / binary-garbage) and 3 new integration cases for the endpoint field (ok / warning / absent). On Docker / manual installs the gate file doesn't exist so this is a no-op (`time_synced` is `null`) — the appliance is the only consumer for now.
-- **Appliance locale defaults endpoint** — `GET /api/v1/system/appliance` returns the hostname/timezone/locale the Bambuddy Appliance setup wizard collects into `/etc/bambuddy/local.toml` during firstboot. New `backend/app/core/local_config.py::read_local_toml` parses the file defensively (missing file → empty dict, invalid TOML → empty dict + warning, non-string values dropped with a warning), so a malformed file never blocks startup. Endpoint returns `{hostname, timezone, locale}` with `null` for any field not present, requires no auth (the frontend i18n bootstrap fetches it before auth might be set up, and the contents are user-set defaults, not secrets). On the frontend, `i18n/index.ts` runs a one-shot `applyApplianceLocale()` hook after init: gated by a `bambuddy_appliance_locale_consumed` localStorage flag so it runs exactly once per appliance, fetches the endpoint, and `i18n.changeLanguage(...)`s if the returned locale is in the supported set. Non-appliance installs (Docker, manual) silently no-op when the file or endpoint is absent. The appliance writes the file via its setup wizard (separate repo: `bambuddy-appliance`); this PR closes the loop for the locale field — hostname and timezone are still applied by the appliance's firstboot.sh via `hostnamectl`/`timedatectl` and don't need a main-app reader. Backend test coverage: 9 unit cases for the reader (missing/empty/comment-only/full/partial/invalid/non-string/unknown-keys/escaped-quotes), 4 integration cases for the endpoint (nulls when no file, full values, partial values, no-auth-required).
-
-- **Unified print dispatch through the queue scheduler (#1625, by @EdwardChamberlain)** — Every print Bambuddy starts now goes through the print queue's scheduler rather than the standalone `background_dispatch.py` path that previously ran in parallel for File Manager prints, archive reprints, and printer-card upload-and-print. Same end-state (a print on the printer), one code path. **Effect on users:** every print is now queueable, cancellable, visible on the queue page, attributable to the user that started it, and runs through the existing filament-deficit check and print-queue ownership model. The "stealth print" that didn't show up in the queue because it bypassed the scheduler is gone. **Architecture.** File Manager **Print**, archive **Reprint**, and printer-card **upload-and-print** all now POST to the existing queue routes (`POST /api/v1/queue/items` with an immediate ASAP scheduled_time) — the scheduler picks it up on the next tick and runs the same dispatch path the existing queue used to. The retired `background_dispatch.py` route + `services/background_dispatch.py` worker + their two test files are removed. The scheduler already had every feature `background_dispatch` did (per-printer locking, status broadcast, error path) plus the deficit / ownership / queue-position machinery, so this is consolidation rather than a rewrite. **Permission scope changes.** Documented in the Security section below (#1625 introduced the `queue:create` requirement on File Manager / archive reprint / upload-and-print). **i18n.** New `queue.actions.startPrint` key added across all 11 locales (the FileManagerPage button's accessible-name on the new path). Parity check holds. **Tests.** All `background_dispatch` test files removed (the routes they covered no longer exist); `test_dispatch_force_timelapse.py`, `test_scheduler_force_timelapse_wiring.py`, and `test_cleanup_forced_timelapse.py` consolidated onto the scheduler since `force_timelapse` now lives there exclusively. **Followup #1625-followup (this drop, listed under Fixed below)** caught three issues from the post-merge audit — ownership gate mismatch on `/queue/{id}/start` and `/queue/{id}/stop`, an ASAP TOCTOU race on empty-scope inserts, and a missing duplicate-position validator on `/queue/reorder` — none of which were introduced by this PR but all of which became more impactful once every print routed through the queue. **Scope.** No DB migration. No new permission (`queue:create` already existed; this PR widens its surface). No frontend behaviour change for users with full permissions — the queue surface absorbs prints that previously skipped it.
-
-- **HMS error actions — Resume / Stop / Check Assistant from the dashboard (#1743, by @Ichicoro, requested in #1419 by @Ichicoro)** — Bambu's HMS error dialog goes from read-only to actionable. The error modal on the printer card now renders the same Resume / Stop / Continue / Retry / Check Assistant / Don't Remind Me etc. buttons that BambuStudio and Bambu Handy show, and each click sends the matching MQTT command back to the printer. Closes the long-standing UX gap that forced users to physically walk to the printer (or open Bambu Handy) just to acknowledge a paused print. **Data source.** A bundled `backend/app/data/hms_actions.json` maps every known printer-model + error code to its list of allowable actions; populated from Bambu's public `e.bambulab.com/hms/GetActionImage.php` endpoint via `scripts/update_hms_actions.py`. The action-ID-to-name mapping (RESUME_PRINTING, CHECK_ASSISTANT, FILAMENT_EXTRUDED, …) matches BambuStudio's open-source enum verbatim — including the `CANCLE` typo, kept on purpose because Bambu's catalog spells it that way and silently fixing it would break the lookup. **Backend.** New `backend/app/services/hms_actions.py` defines an `HMSAction` `StrEnum` and `get_actions_for_error_code(device, error_code)` lookup; loaded once at module import via `Path(__file__).resolve().parent.parent / "data" / ...` so the JSON resolves regardless of CWD (systemd unit, Docker entrypoint, pytest from `backend/`). `BambuMQTTClient._parse_data` looks up the action list at HMS-parse time on both error sources — the structured `hms[]` branch and the per-print `print_error` short-code branch — and attaches it to `HMSError.actions` together with a `job_id` snapshot from `self.state.subtask_id` so the action survives a subsequent job change. New `POST /api/v1/printers/{id}/hms/execute-action` route (`HmsActionBody` schema; permission `PRINTERS_CONTROL`) dispatches the click. **Dispatcher (`BambuMQTTClient.execute_hms_action`).** A `match` statement maps each `HMSAction` to its MQTT command — `resume` / `stop` with `err`+`param=reserve`+`job_id` for the HMS-aware actions; `idle_ignore` with `type=0` (one-time) vs `type=1` (persistent) so Bambu's "Don't Remind Me" / "No Reminder Next Time" actually disable the warning across prints; `ams_control` with `param=done` / `resume` / `abort` for filament-load dialogs; bare `clean_print_error` (matches the existing `clear_hms_errors` shape — no leaked `print_error` body field); `clean_print_error` + `uiop` chained for `DBL_CHECK_OK`; `refresh_nozzle`, `buzzer_ctrl mode=0` (fire alarm), `auto_stop_ams_dry`, `close_air_filt` for the standalone actions. UI-only actions (`CHECK_ASSISTANT`, `JUMP_TO_LIVEVIEW`, `OK_JUMP_RACK`, `REMOVE_CLOSE_BTN`, `LOAD_VIRTUAL_TRAY`, `CANCLE`, `DBL_CHECK_CANCEL`) intentionally publish nothing — they exist for label parity with BambuStudio's modal where the printer's own screen drives them. Unknown actions fall through to `return False` + warn log so the route surfaces them as 4xx rather than silently no-opping. Every command pairs with a `pushing.pushall` echo so the state stream refreshes on the next tick and the modal closes correctly. **Schema hardening.** `HmsActionBody.print_error` validated as `min/max_length=8` + `pattern=r"^[0-9A-Fa-f]{8}$"`; `action` and `job_id` length-capped. Stray input can't reach the dispatcher's `match`. **Frontend.** `HMSErrorModal.tsx` renders a wrap-flex row of buttons under each error description, sized to fit on the printer-card panel without overflowing on narrow viewports. The mutation calls the new endpoint, invalidates the printerStatus query, and shows the new `hmsErrors.actionSuccess` / `hmsErrors.actionFailed` toast. The button label is the translated action name from `hmsErrors.actions.` — never the raw enum — so a forgotten translation falls back to the English action name rather than a key string. **i18n.** 33 action labels + 2 toast keys translated in all 11 locales (`de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW`). No English fallback per [[feedback_translate_dont_fallback]]. Parity check 5381 leaves per locale. **Tests.** 31 new cases in `test_hms_actions.py` — 5 catalog-lookup (known A1 error returns actions; unknown device → empty; unknown error → empty; underscore-form `0300_8070` doesn't match the catalog's no-separator key; enum StrEnum value contract holds including the `CANCLE` typo) and 26 dispatcher cases that pin every `HMSAction` branch to its exact MQTT payload — resume carries err+param+job_id; `IGNORE_RESUME` / `NO_REMINDER_NEXT_TIME` use `idle_ignore type=0`; `IGNORE_NO_REMINDER_NEXT_TIME` / `DONT_REMIND_NEXT_TIME` use `type=1` (persistent variants — were incorrectly bucketed together in an earlier draft); `clean_print_error` body is bare; `DBL_CHECK_OK` chains clean + uiop_close; uiop's `err` is the already-string short code (not `f"{x:08X}"` against a str, which would TypeError); plain resume vs HMS-aware resume distinguished; UI-only actions publish nothing; unknown action returns False; `pushing.pushall` echo fires after every command. Full `pytest -n 30` green (6471/6471). Backend `ruff` clean. Frontend `npm run build` clean. **Scope.** No DB migration. No new permission. The HMS modal is opt-in by user click — installs with no HMS errors see no behaviour change. The 9009-line catalog is shipped as a single static JSON; regenerating it later is a `python scripts/update_hms_actions.py` run away.
+### Fixed
+- **Assign-spool picker note now visible on mobile (#793 follow-up, reporter @EmcetPL)** (`b2b04fc4`)
+- **API keys with Manage Library permission can rename / delete / move library files (#1832, reporter @MorganMLGman)** (`4c795636`)
+- **Forecasting groups spools by colour + Forecast UI rework (#1814, by @Keybored02)** (`3cbdba0c`)
+- **False-positive "Print Stopped" notification on reprint after MQTT reconnect (#1807, reported by @volodymyr-doba)** (`5e008744`)
+- **Unknown-tag modal no longer pops for slots with no RFID** (`8b72b305`)
+- **SpoolBuddy "Assign to AMS" preserves the user's slicer preset instead of pushing Generic (#1815, reported by @Bgabor997)** (`cd5a02c0`)
+- **H2S active-tray highlight no longer stuck on AMS slot 1 during external-spool prints (#1822, reported by @ojimpo)** (`2bd2bce3`)
+- **`require_previous_success` no longer permanently blocks a printer's queue after a failure (#1818, reported by @jmassardo)** (`ba7af59b`)
+- **Archives "Step 4" docs link no longer 404s (#1812, reported by @Spanholz)** (`c52aba66`)
+- **H2C nozzle pick from Bambu Studio preserved on dual-nozzle rack variant + VP slicer-field intake (#1780, reported by @mkoreen)** — Race window bumped to 5s with retroactive stamp; VP intake key mismatch corrected; rack-swap nozzle pick forwarded to dispatch. (`71b0575f`, `30c2e263`, `b6916055`)
+- **Connection diagnostic no longer reports false camera-port warning on A1 / A1 Mini / P1 (#1799 closing #1798, by @lesbass / Stefano Maffeis)** (`8e99b0c8`)
+- **Print-complete notification no longer drops the finish photo when the FINISH-state fallback fires (#1790, reported by @needo37)** (`a9bf6f1f`)
+- **Chamber-fan badge hidden on open-frame Bambu printers** (`568f220a`)
+- **In-app "Install Update" on Windows installer switched to release-asset update flow** (`b7ff72d8`)
+- **Mid-print AMS Backup spool-switch correctly splits weight instead of crediting all to the second spool (#1771, reported by @biduleman)** (`a70c2a2d`)
+- **AMS history modal respects theme background variant in stats modal** (`55510871`)
+- **Completion notification scoped to printed plate on multi-plate 3MFs (#1785)** (`964015de`)
+- **Docker installer escalates on EACCES instead of failing on `/opt/bambuddy` (#1774, reported by @jmoore-skild)** (`03d09238`)
+- **Archive thumbnails rendered server-side when the sidecar slice skips them (#1759, reported by @VID-PRO)** (`d2232e02`)
+- **Post-#1661 printer-card cleanup — test fixtures + hover-card fly-in removal** (`0128869d`)
+- **Local Presets page: deleted row optimistically removed instead of staying visible until refetch returned** (`0eed9865`)
+- **SpoolBuddy inventory search matches spool ID, slicer filament name, and storage location (#1738, reported by @shaddowlink)** (`355d08a8`)
+- **Sidebar entries for Files / Archives / Queue no longer hidden from non-admin users with granular `*_read` access (#1755, reported by @knifesk)** (`7f150886`)
+- **Push notification for "Printer offline" actually fires (#1752, reported by @saint-hh)** (`2cbbd1ee`)
+- **Auth preserves the original URL across login + OIDC round-trip (#1750)** (`ed1683fe`)
+- **Archives backfill NULL `created_at` + tolerate NULL in response (#1732)** (`8faaeb96`)
### Security
-- **Print permission scope change for queue-only dispatch** — All UI print-entry points now route through the print queue instead of direct background dispatch, which changes the permissions needed for some actions. File Manager **Print** now requires `queue:create` (previously `printers:control`). Printer-card upload-and-print now requires both `library:upload` and `queue:create`. Archive reprint buttons now require `queue:create` plus the existing archive reprint ownership permission (`archives:reprint_own` or `archives:reprint_all`). Installations with custom groups/API keys that granted `printers:control` for immediate printing but did not grant `queue:create` must add `queue:create` to keep those print actions available. Grant `queue:create` carefully: ASAP queue items are eligible for immediate dispatch, so it is now the permission that authorizes starting queued prints from File Manager, Archives, and upload-and-print flows.
-- **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. `vitest@4.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-link` → `@tiptap/pm` → `prosemirror-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.
-- **`dompurify` 3.4.10 → 3.4.11** — Follow-up patch closes a moderate-severity advisory affecting `setConfig()` callers: the previous hook clone-guard added in 3.4.7 could be bypassed via `setConfig()`, leaving a permanent `ALLOWED_ATTR` pollution that the next `sanitize()` call inherited. **Bambuddy's exposure is nil** — `git grep DOMPurify.setConfig` returns zero hits across the entire codebase; all three sanitisation sites (`frontend/src/pages/MakerworldPage.tsx`, `frontend/src/pages/ProjectDetailPage.tsx`, `frontend/src/components/ProjectPageModal.tsx`) call `DOMPurify.sanitize(html)` or `DOMPurify.sanitize(html, {ALLOWED_TAGS, ALLOWED_ATTR})` directly, never through `setConfig()`. The bump is taken as defence-in-depth to keep XSS-sensitive surface area current and to silence `npm audit` so future audit-fix runs don't auto-bundle unintended changes. **Mechanical lockfile bump only:** the existing `^3.4.10` range already permitted 3.4.11, so `package.json` is unchanged; `package-lock.json` updates the resolved URL + integrity hash for the one entry. Verification: `npm audit` reports 0 vulnerabilities, `MakerworldPage.test.tsx`'s 12 DOMPurify sanitisation cases pass, `npm run build` clean.
-- **Precautionary floor pins for pydantic-settings 2.14.2 + msgpack 1.2.1** — pip-audit surfaced two advisories that are not reachable in shipped Bambuddy but were flooring at vulnerable versions. **`pydantic-settings` 2.0.0 → 2.14.2** in `requirements.txt` clears GHSA-4xgf-cpjx-pc3j (`NestedSecretsSettingsSource` with `secrets_nested_subdir=True` follows symbolic links pointing outside the configured `secrets_dir`, reading out-of-tree files into settings values and bypassing the documented `secrets_dir_max_size` cap; affected `>=2.12.0,<2.14.2`). **Exposure: nil.** `grep -rn "NestedSecretsSettingsSource\|secrets_nested_subdir\|secrets_dir" backend/` returns zero hits — Bambuddy uses pydantic-settings only for env-var-backed config, never for the secrets-dir loader. **`msgpack` 1.2.1** floor-pinned in `requirements-dev.txt` next to `pip-audit>=2.7.0` to clear GHSA-6v7p-g79w-8964 (an `Unpacker` instance reused after catching an error can crash with SEGV; under repeated unpacking of untrusted input from an external source, this is a DoS vector). **Exposure: nil.** `grep -rn "import msgpack\|from msgpack" backend/` returns zero hits — msgpack enters Bambuddy's tree only as a transitive of `CacheControl`, which is itself pulled by `pip-audit` (the very tool that produced the report). Not a runtime dep of the shipped app. Both pins are taken as defence-in-depth / audit hygiene so the next `pip-audit` run is clean and a future *reachable* advisory in either package isn't masked by the existing noise. No code change, no behavioural change, no test change.
-
-- **Backend dependency security floor raises (cryptography / python-multipart / starlette)** — pip-audit December 2026 cycle surfaced six advisories across three direct deps; floors in `requirements.txt` lifted to the documented fix releases, plus one transitive co-bump for resolver compatibility. **`cryptography` 46.0.7 → 48.0.1 floor** (resolver picks 49.0.0 within the new floor) — clears GHSA-537c-gmf6-5ccf (non-contiguous Python buffer handling that could overflow on APIs accepting buffer protocol input). **Release-notes audit (done before bump):** v47.0.0 dropped Python 3.8 + OpenSSL 1.1.x + binary elliptic curves (SECT*) + Camellia + CFB/OFB/CFB8 modes (moved to `cryptography_decrepit`); v48.0.0 dropped `PUBLIC_KEY_TYPES` / `PRIVATE_KEY_TYPES` type aliases. Bambuddy's grep is clean across every one of those: `core/encryption.py` uses Fernet (AES-128-CBC + HMAC), `services/spoolbuddy_ssh.py` uses ed25519, `services/virtual_printer/certificate.py` uses RSA + x509 + ExtendedKeyUsageOID. Python 3.13 + OpenSSL 3.x on container, so the version-floor bumps are no-ops for us. **`python-multipart` 0.0.27 → 0.0.31 floor** (resolver picks 0.0.32) — clears CVE-2026-53538/53539/53540 in the multipart parser surface (boundary length capped at 256 bytes, RFC 2231 continuation handling, Content-Length non-negative validation, bounded header field name size before validation). **Behavioural changes audited:** 0.0.30 stopped recognising RFC 2231/5987 extended `filename*` / `name*` parameters in incoming bodies — Bambuddy emits these on outgoing Content-Disposition response headers (`utils/http.py:17`) but doesn't parse them on the request side, and clients that include both `filename=` and `filename*=` keep working via the plain `filename=` fallback (slight cosmetic difference for non-ASCII filenames in uploads). 0.0.30 also tightened form-urlencoded parsing to treat only `&` as field separator — every Bambuddy client (browser, BambuStudio, OrcaSlicer) already uses `&`. **`starlette` 1.1.0 → 1.3.1 floor** — clears CVE-2026-54282/54283 (FormParser `max_part_size` / `max_fields` limits now actually enforced after being declared-but-ignored in earlier releases; `StaticFiles.lookup_path` rejects absolute paths; `FileResponse` clamps oversized suffix range requests; `URL.replace()` IndexError fix). **Critical pre-bump check:** the newly-enforced `max_part_size=1MB` default would have broken every file upload (`UploadFile = File(...)` in `inventory.py:1127`, `projects.py:886/1053/1780`, `library.py:1787`, `local_presets.py:82`, `external_links.py:166`, `local_backup.py`) if it applied to file streams. Inspected the `MultiPartParser.on_part_data` source: the size check at `if self._current_part.file is None:` only fires for **text** form fields, not file streams — so file uploads of arbitrary size still pass through unaffected. Text form bodies in Bambuddy are login credentials and similar small values, well under the 1MB ceiling. **Side rename:** `backend/app/api/routes/mfa.py:470/1364/1428` replaces 3 references of `status.HTTP_422_UNPROCESSABLE_ENTITY` (deprecated in starlette 1.3.x) with `HTTP_422_UNPROCESSABLE_CONTENT`. Same 422 wire status; silences the 3 deprecation warnings under our own ownership (the two remaining warnings come from FastAPI internals — upstream's to fix). **`pyopenssl` 26.0.0 → 26.3.0 floor** — **NOT a security fix**; required because pyOpenSSL `<26.3.0` caps `cryptography<47` in its install_requires, so without an explicit floor the resolver either downgrades cryptography below the GHSA-537c-gmf6-5ccf fix line or installs an inconsistent pair (pip's resolver warns but proceeds). Bambuddy has no direct `from OpenSSL ...` imports — pyOpenSSL is pulled transitively by `asyncssh` + `pywebpush`. **Verification:** `pip-audit` clean, `pip check` clean, `ruff check backend/` clean, backend `pytest -n 30` 6167/6167 in 86.55s. No DB migration, no API surface change, no permission change, no frontend change.
-
-### Added
-- **AMS Filament Backup is now first-class across the deficit check, the printer card, and a new BambuStudio-style backup modal (#1762, reported by @jpcast2001 + @Arn0uDz)** — Four tightly coupled changes that close the gap reporter @jpcast2001 hit on the dual-AMS X1C farm. Reporter scenario: PLA Basic in AMS-1 slot 1 with low remaining grams, the same PLA Basic in AMS-2 slot 1 with plenty — Bambuddy still blocked the print with an "insufficient filament" warning because per-slot accounting never noticed the backup peer. Reporter disabled `disable_filament_warnings` as a workaround; @Arn0uDz hit the same shape on a different printer and noted "Print Anyway" didn't unstick them either. The single global AMS Filament Backup toggle that shipped in 0.2.5b1 (#1766) was the prerequisite for the firmware-level switch, but every Bambuddy surface still treated each slot as isolated. **What's new on each surface:**
-
- **Backup-aware deficit aggregation (the load-bearing fix).** `backend/app/services/filament_deficit.py::compute_deficit_for_queue_item` now reads `PrinterState.ams_filament_backup` via `printer_manager.get_status` and, when backup is ON, pools `remaining_grams` across every same-material assigned spool on the printer before deciding whether to block. Material identity uses the firmware's actual rule: same Bambu filament preset ID (`Spool.slicer_filament`, e.g. `GFA00`) AND same colour (with `1A1A1AFF` normalised to match `1A1A1A` — alpha stripped, hex uppercased). The preset identifies the filament profile (PETG HF, PLA Basic, etc.); the colour pins the variant. Three PETG HF spools in different colours all share the same preset but absolutely don't back each other up — the firmware would correctly swap PETG HF but the print would change colour mid-run. User-tagged spools without a preset get a unique-per-spool key so they never pair with anything else, matching the firmware: Bambu's backup logic relies on the preset, and grouping on cosmetic material+colour match alone would let two visually-identical but materially-different spools be treated as backups. Same `(catalog-id, colour)` rule on the Spoolman side via `filament.id` + `color_hex`; spools linked to different filament catalog entries never pool even if their material+colour strings match. **Dual-extruder scoping is load-bearing here:** H2D / H2C / X2D firmware cannot cross extruders even when bit 18 of `print.cfg` is set, so the pool is per-extruder-side via `PrinterStatus.ams_extruder_map` plus `is_dual_nozzle_model()`. Single-extruder printers collapse everything to one pool and ignore the map. The check still emits per-slot `FilamentDeficit` rows when the *total* required of a material on an extruder side exceeds the *total* available of that material, so the UI's "slot X is short" message still resolves to a specific slot the user can act on — it just doesn't fire spuriously when the firmware will actually save them.
-
- **BambuStudio-style backup modal opens from the badge.** The existing AMS Backup badge on the Filaments section header (#1766) now opens a dedicated modal instead of toggling state on click. The modal renders one SVG ring graphic per backup pair — each ring filled with the filament colour, the material name + rotation count (`N× ↻`) in the centre, and member slot labels distributed around the colour band on rounded contrast-aware pills (semi-opaque black on bright fills, semi-opaque white on dark) so the labels stay legible regardless of the spool colour. Closely modelled on Bambu Studio's "Auto Refill" widget. Lone slots are intentionally suppressed — the ring graphic is the answer to "which slots will save me when this one runs out"; everything else is visual noise. On dual-extruder printers (H2D / H2C / X2D), each ring carries a compact `R` / `L` badge in the top-left corner instead of section headers — and the badge ONLY appears when the extruder map carries TWO distinct values across the AMS units, so single-nozzle printers misflagged as dual or printers with routing data not yet reported collapse cleanly to no-badge rendering. Modal closes on **Esc keypress** (window-level listener registered while open, cleaned up on close), click-outside, or the close button. Theme-aware via CSS variables (`var(--bg-secondary)` / `var(--text-primary)` / etc.) matching `AMSHistoryModal`, so the modal follows whichever background variant the user has picked (neutral / warm / cool / oled / slate / forest). The badge itself stays in the Filaments section header where #1766 put it — its `onClick` was rewired from "directly toggle the backup state" to "open the modal", with the same `setAmsFilamentBackup` mutation hooked to the toggle inside the modal. The new `computeBackupGroups(amsUnits, amsExtruderMap, isDualNozzle): BackupGroup[]` helper in `utils/amsHelpers.ts` is the modal's data source — it returns one entry per non-empty slot, sorted with pairs first, then by material name, then by global tray id for deterministic rendering. Identity uses the same strict `(preset, colour)` rule as the backend. HT AMS (single-tray modules with `ams_id ≥ 128`) participate in groupings via `getGlobalTrayId`, so an HT slot can pair with a regular AMS slot when both hold the same preset and colour. Defensive dedup by `ams.id` (first occurrence wins) hardens the helper against duplicate entries that have been observed in the wild on VP-aggregated switch printers and MQTT partial-update edge cases — without the dedup, a single physical slot could render in two different rings.
-
- **Active-print per-slot mapping pill while RUNNING / PAUSE.** While the printer is mid-print, each slot tile referenced by `PrinterStatus.ams_mapping` (already on the wire, from the slicer's filament-map captured during dispatch) gets a small "P1 / P2 / P3 …" pill in the top-right corner — opposite the backup-group dot — naming which print-slot is mapped to this AMS slot. Catches the secondary report from @jpcast2001's comment 2 verbatim: queue job set for "any X1C", scheduler bumped it to a printer with mismatched filament, no way to verify mid-print whether the right slots are loaded. With the pill the mismatch is visible the instant the print starts. The existing single-slot `ring-2 ring-bambu-green` highlight for `effectiveTrayNow` keeps its meaning ("the currently-active extrusion source RIGHT NOW") — the pill is per-slot static "this slot is filament N in the active print," not per-tick dynamic.
-
- **Print Anyway diagnostic log (Arn0uDz follow-up).** `_block_on_filament_deficit` in `print_scheduler.py:1983` now logs at INFO when it honours `item.skip_filament_check`, so a future "Print Anyway didn't work" report (the third commenter on #1762 hit this shape) has an actionable line in the standard support bundle without needing debug logging enabled. The route-side log at `print_queue.py:1278` is unchanged. Without logs from the original report we can't isolate the user's failure mode (the wire path on both ends still looks correct on inspection), so this is the minimal trace required to investigate the next occurrence — bundled in the same drop because Block 1 makes the original symptom disappear for users who had backup ON anyway. **Tests.** 8 new backend cases in `test_filament_deficit.py::TestFilamentDeficitBackupAware` pin every dimension: pool covers the assigned-slot shortfall → no deficit (the reporter scenario, with matching `slicer_filament` preset + matching colour); pool insufficient → deficit emitted with the correct slot id; peer slot holds a DIFFERENT preset → no pool, deficit fires; backup OFF → strict regression with the pre-#1762 per-slot accounting (using identical inputs to the "pool covers" case but flipping the toggle); dual-extruder printer with a peer on the OPPOSITE side → deficit fires because the firmware can't cross; STRICT-rule — two spools with material+colour match but NO preset must NEVER pair; COLOUR-strict — same preset + DIFFERENT colours must NOT pool (the reporter screenshot scenario, three PETG HF in different colours); COLOUR normalisation — 6-char and 8-char hex of the same RGB pool correctly (`000000` matches `000000FF`). 13 frontend cases in `PrintersPageBackupGroups.test.ts` pin `computeBackupGroups`: empty for missing input; ignores empty slots; pairs via preset; no-preset spools NEVER pair even on attribute-tuple match; different presets never cross; SAME preset + DIFFERENT colours don't pair; colour-hex 6-char and 8-char normalisation; lone slots returned alongside pairs in the same list; dual-extruder scopes per-side both ways; HT AMS pairs with regular AMS via `getGlobalTrayId`; preserves display name + tray colour for the modal swatch; DEFENSIVE dedup of duplicate `ams.id` entries (first wins). 10 modal render cases in `AmsBackupModal.test.tsx`: closed → null; ring renders for pairs and OMITS lone slots; **Esc keypress closes the modal**; Esc is a no-op after the modal closes (listener actually unmounts); toggle reflects ON state + fires onToggle(false) on click; toggle disabled when state unknown (A1 family); toggle disabled when permission missing; no-pairs empty state when no pair can form; R/L badges render when extruder map carries two distinct values; R/L badges absent when the map collapses to one extruder. Existing 8 `test_filament_deficit.py` + 60 `PrintersPage.test.tsx` cases stay green — the no-backup path is a strict no-op vs the pre-#1762 logic. **i18n.** 12 new keys × 11 locales for the modal + the active-print pill (`printers.amsBackup.modalTitle / modalHelp / modalNoSlots / modalNoPairs / stateOn / stateOff / stateUnknown / extruderRightShort / extruderLeftShort`, plus `printers.activeJobSlot.title / ariaLabel`) translated in de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW. Parity check 5253 leaves per locale, no English fallback; "AMS Filament Backup" is a Bambu product/firmware name and is allowlisted as a cognate where the European locales keep it verbatim. **Scope.** No DB migration, no new permission. The global Filament Backup badge stays where #1766 put it (Filaments section header on the printer card) — firmware reality is one bit on `print.cfg`, and moving the toggle per-AMS would misrepresent that. The badge click no longer toggles directly; it opens the modal, where the same `setAmsFilamentBackup` mutation is wired to the toggle. No schema change to `FilamentDeficit` — same shape, same wire payload, same 409 response under `code: insufficient_filament`. No change to the `disable_filament_warnings` setting (#720) — when on, the deficit check is still a no-op regardless of backup state. **Behaviour shift worth flagging.** Pre-PR, prints could be blocked with "insufficient filament" even when the firmware would actually have switched to a same-`(preset, colour)` peer mid-print. Post-PR, those prints dispatch. Users with backup misconfigured at the firmware level (e.g. FTS routing wrong) may see prints dispatch that previously got blocked at the deficit check; the printer would then fail mid-run rather than at queue-start time. The trade-off is correct — the warning shouldn't fire when backup will save you — but worth surfacing for anyone debugging post-upgrade.
-
-- **Inline finish-photo embed in failure-event emails + `user_print_*` template disambiguation (#1792, reported by @elit3ge)** — Two related changes to the notification stack. **(1) Template-driven inline finish-photo in email.** Pushover / Telegram / Discord / ntfy users already get the finish-photo JPEG attached to terminal-print notifications (`print_complete` / `print_failed` / `print_stopped` event types), thanks to the capture path shipped in 0.2.5b1 (#1397) that extracts the last timelapse frame at print end and loads up to 2.5 MB into `archive_data["image_data"]`. Email was the one provider that dropped those bytes on the floor — text-only body, no visual context for the reporter's "Reason: unknown" failure mails. `notification_service._send_email` (`backend/app/services/notification_service.py:413`) now accepts `finish_photo_url` alongside `image_data` and the dispatcher (`_send_to_provider` at `:745`) threads the URL from the rendered template variables dict. **Inline embed is opt-in via the existing `{finish_photo_url}` template variable** — first draft of this fix unconditionally inlined the photo whenever bytes were present, which @maziggy correctly flagged as bypassing the template system ("standard is to have variables for all available items in a template"). The contract now: if the user puts `{finish_photo_url}` in their email template body, the URL substring in the rendered body triggers the multipart/related shape — HTML part replaces the escaped URL in-place with ` ` (so the image appears WHERE the variable was, not stapled to the bottom), plain-text part keeps the URL as a clickable link, MIMEImage attached inline with `Content-ID: ` per RFC 2392. If the template doesn't reference the variable, single-part text-only — no surprise image. Default templates are unchanged; reporter (and any user who wants this) edits their `print_complete` / `print_failed` / `print_stopped` body once to add the variable. XSS hygiene: rendered body is `html.escape`d before the URL→` ` swap, newlines become ` `. Pushover/Telegram/Discord/ntfy senders untouched — their pre-existing "auto-attach whenever `image_data` is set" behaviour stays because their bodies aren't HTML-templatable for inline images anyway. **(2) `user_print_*` template names get an " Email" suffix.** Same reporter surfaced a separate confusion: the Message Templates list showed "Print Completed" and "User Print Completed" side-by-side with no cue they're different dispatch paths — the first is a provider-level broadcast to whatever notification channels the admin configured (ntfy/pushover/telegram/discord/email/webhook/homeassistant), the second is a per-user SMTP-only email to the user who submitted the job (requires advanced auth + `user_notifications_enabled` toggle + user has email + per-user pref opt-in). The `EVENT_NAMES` display map in `backend/app/api/routes/notification_templates.py:51` already used the disambiguated "User Print Completed Email" label, but the seed wrote the short name to the DB, so the UI rendered the ambiguous one. Fresh installs now get the suffixed name straight from `DEFAULT_TEMPLATES` (`backend/app/models/notification_template.py:198+`). Existing installs get the rename via a new `_migrate_rename_user_print_template_names` (`backend/app/core/database.py:3081+`) that runs on startup and updates rows for the four `user_print_*` event types WHERE the name still matches the old default — admin-edited names are preserved. Standard SQL UPDATE works on both SQLite and Postgres without dialect branching. **Tests:** 6 new `TestEmailProvider` cases in `backend/tests/unit/services/test_notification_service.py` pinning the template-driven contract (no-image-no-URL → text-only, image-without-template-reference → STILL text-only, URL-in-body + bytes → multipart/related with cid, URL-arg-missing → text-only defence-in-depth, body-escape hygiene, URL→` ` in-place swap). 5 new migration cases in `backend/tests/unit/test_user_print_template_rename_migration.py` covering default-rename, user-edited preservation, provider-template don't-touch, second-run idempotency, empty-table fresh-install no-op. 11/11 + 140/140 adjacent notification tests green. Ruff clean. **Verified end-to-end** against a real SMTP provider with a real 48 KB finish-photo JPEG — Gmail rendered the inline image where the URL marker was in the body.
-
-- **Dedicated "AI Failure Detection" notification event (#1794, reported by @maziggy from a user report)** — Obico failure detection now fires its own notification event (`on_ai_failure_detection`) instead of riding the multiplexed `on_printer_error` toggle. Reporter (P1S, Discord provider) had Obico enabled with `obico_action=notify`, detection was firing correctly per the logs, every other Discord notification was working — but spaghetti detections never reached Discord. **Root cause.** `obico_actions._notify` at `obico_actions.py:75` was calling `notification_service.on_printer_error(..., error_type="ai_failure_detection")`. The notification service's provider filter at `notification_service.py:722-725` requires the SUBSCRIBED-event boolean column to be True; the `on_printer_error` column defaults to False; the reporter's Discord provider was created without explicitly enabling Printer Error. The user couldn't have found the right toggle even if they'd known to look — the UI labels it "Printer Error" with no hint that flipping it also subscribes to AI detection. The same toggle multiplexed three distinct events (HMS hardware errors at `main.py:1248` + Obico spaghetti + a `error_type="ai_failure_detection"` discriminator passed in the variables payload), so a user who wanted spaghetti alerts but not chamber-fan-stalled HMS pages had no way to express that. **Fix.** New `on_ai_failure_detection` Boolean column on `notification_providers` (defaults False — matches the conservative default of every other opt-in event); new `notification_service.on_ai_failure_detection(printer_id, printer_name, task_name, confidence, action, db, image_data)` method following the exact shape of `on_printer_error` (mirrors variable handling, template fan-out, provider filter, fail-open under quiet-hours / digest); new `ai_failure_detection` template entry seeded by `seed_notification_templates` with variables `{printer}`, `{task_name}`, `{confidence}`, `{action}`. The seeder only adds templates whose `event_type` is missing, so existing installations get the new template on next start without clobbering customised ones. `obico_actions._notify` swapped to the new method. **Migration.** Branched SQLite (`DEFAULT 0`) vs Postgres (`DEFAULT false`) per the existing stock-alert migration shape at `database.py:2750` — Postgres rejects `DEFAULT 0` for BOOLEAN columns. Existing providers receive the column with the conservative False default; they continue NOT receiving Obico notifications UNTIL they explicitly toggle the new "AI Failure Detection" event ON. This is the intended UX: previously the toggle was on `Printer Error`, which the reporter had OFF, so today they get nothing; after this change they still get nothing until they opt in via the dedicated toggle, but now they can find the toggle without trial-and-error. **Frontend.** New toggle row in `NotificationProviderCard.tsx` (between Printer Error and Low Filament) with a description line "Notify when Obico AI detects a possible print failure" so users discover the link to Obico without having to read source. New summary badge ("AI Failure Detection" in fuchsia) in the collapsed card view so admins can see at a glance which providers route AI alerts. New toggle in `AddNotificationModal.tsx` Printer Status section with matching state hook (`onAiFailureDetection`) wired through the create + update payload. ntfy per-event priority block also picks up the new event when enabled, matching how Printer Error and the stock-alert events behave there. **Schema.** `NotificationProvider` model + `NotificationProviderBase`/`NotificationProviderUpdate` schemas + `_provider_to_dict` route serialiser + create route + PATCH route (the latter uses `model_dump(exclude_unset=True)` so it picks up the new field automatically). Frontend `NotificationProvider` type + the update-payload variant. **i18n.** Two new keys — `notifications.aiFailureDetection` (label) and `notifications.aiFailureDetectionDescription` (help text) — translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5242 leaves per locale, no English fallback. **Tests.** 4 new backend cases in `test_notification_service.py::TestAIFailureDetectionNotifications` (dispatch uses the new event field — NOT the legacy multiplexed one; provider with only `on_printer_error=True` is NOT notified — the regression guard for the reporter's symptom; variables include task_name + 2-decimal-formatted confidence + action; empty task_name falls back to "current job"). 3 new backend cases in `test_obico_actions.py` (`execute_action(action='notify')` calls `on_ai_failure_detection` and explicitly does NOT call `on_printer_error`; the `pause` action still pauses + notifies; notification-service exceptions are swallowed so a transient Discord blip can't kill the Obico detection loop). 5 new frontend cases — 4 in `NotificationProviderCardAiFailureDetection.test.tsx` (badge renders when ON; absent when OFF; toggle appears in expanded settings; toggling PATCHes the correct field and explicitly NOT `on_printer_error`) and 3 in `AddNotificationModal.test.tsx` (toggle renders in Printer Status section; save persists the new field without touching `on_printer_error`; ntfy priority block includes the event when enabled). Existing 87 `test_notification_service.py` + 52 Obico tests + 65 `NotificationProviderCard*` / `AddNotificationModal*` tests still green. Backend `pytest -n 30` clean; ruff clean; `npm run build` clean; ESLint clean. **Scope.** No change to HMS hardware-error notifications — `main.py::on_printer_error` callers still fire the `on_printer_error` event with `error_type` shapes like `"AMS Error"` / `"Heating Error"`, unchanged. The `on_printer_error` column stays on the table (default False, used for HMS only). Users who had it ON for HMS errors keep getting HMS notifications; what they LOSE is silent AI-failure dispatch on the same toggle, which most users with HMS-on never received anyway because `error_type="ai_failure_detection"` was the same value `obico_actions._notify` hardcoded. The full Obico action surface (`notify` / `pause` / `pause_and_off`) is unchanged on the dispatch side — `execute_action` still pauses + cuts plug power for `pause_and_off`; the only thing that moved is which notification-service method runs the fan-out.
-
-- **Page-wide drag-and-drop upload on the File Manager (#1510, requested by @maikolscripts)** — File Manager gains the same drag-and-drop upload surface that the Archives page has had: drop any file anywhere on the page and the upload modal opens pre-populated with the dropped files, no need to click the **Upload Files** button first. The hardcoded `"Upload 3MF"` flow was the only path before this change. Unlike the Archives variant — which filters dropped files to `.3mf` only — the File Manager drop zone accepts whatever the upload modal itself accepts (3MF, STL, ZIP, images), so the page-wide surface is never more restrictive than the button it shortcuts. Permission-gated on `library:upload` so a viewer-tier user can't accidentally trigger the overlay. **Shared hook.** `frontend/src/hooks/usePageFileDrop.ts` is the new home for the drag-handler set — `isDraggingOver` state, `dragHandlers` to spread on the wrapper, optional `extensions` filter, optional `onRejected` callback for "you dropped something we won't accept" toasts, `disabled` flag for permission gating. Archives and File Manager both consume it; future drop-zones can opt in without re-implementing the cancel-safe logic. **`FileUploadModal.initialFiles` prop.** Modal accepts a `File[]` to pre-seed itself on first mount via a `seededInitialRef` guard so the same files don't re-add on subsequent renders. Existing manual-open paths (Upload Files button) pass nothing and behave unchanged. **i18n.** New key `fileManager.releaseToUpload` translated in all 11 locales (en: Release to upload, de: Loslassen zum Hochladen, es: Suelte para subir, fr: Relâcher pour téléverser, it: Rilascia per caricare, ja: 離してアップロード, ko: 놓아서 업로드, pt-BR: Solte para enviar, tr: Yüklemek için bırakın, zh-CN: 释放以上传, zh-TW: 釋放以上傳); existing `fileManager.dropFilesHere` reused. Parity 5240 leaves × 11 green, no English fallback. **Tests.** 13 new cases in `src/__tests__/hooks/usePageFileDrop.test.tsx` covering: overlay on dragenter, non-file payload ignored, child-element dragLeave keeps overlay (relatedTarget inside wrapper), outside-element dragLeave hides it, null relatedTarget hides it (cursor left window), document drop / dragend / Escape all reset (the three cancel paths the prior inline implementation missed — see the Fixed entry), drop with mixed file types filters by extension, onRejected fires when extension filter drops everything, disabled is a no-op, overlay clears on successful drop. Existing 85 cases across ArchivesPage / FileManagerPage / FileManagerExternalFolder vitest still green. ESLint clean; `npm run build` clean.
-
-- **Sort Printers page by ETA (#1609, requested by @forgecrafttechnologies-source)** — The Printers page sort dropdown gains a fifth option, **ETA**, beside the existing **Name / Status / Model / Location**. Sorts the fleet by remaining print time so the printer that's finishing next sits at the top — the reporter's use case is staging the next job's filament ahead of time without scanning every card. **Tier ordering.** Tier 0 = currently printing with a known `remaining_time > 0`, sorted ascending by remaining minutes (soonest first); Tier 1 = currently printing without an ETA yet (post-`start_print` window before the slicer reports total time); Tier 2 = idle / finished; Tier 3 = offline. Tiebreaker within every tier is printer name, so two printers with the same ETA — or two idle printers — stay in a stable alphabetic order. The ascending / descending direction button still applies after tiers resolve, so descending puts offline printers at the top for operators triaging the fleet for connectivity issues. **Data source.** The cached `remaining_time` (minutes) on the per-printer status query (`['printerStatus', id]`) — the same field the per-card "ETA … min" label already reads from on `PrintersPage.tsx:3633` and the fleet-wide "next finish" badge already aggregates on `PrintersPage.tsx:996`. No new backend query, no new round-trip; the sort consumes data that's already in the React Query cache and updated on every WebSocket push. **No grouping.** Unlike `status` / `model` / `location` sorts (which group rows under section headers), the ETA sort renders a flat list — each printer's ETA is unique so grouping would just produce a header per row. **i18n.** New key `printers.sort.eta` translated in all 11 locales (en: ETA, de: Restzeit, es: Tiempo restante, fr: Temps restant, it: Tempo rimanente, ja: 残り時間, ko: 남은 시간, pt-BR: Tempo restante, tr: Kalan süre, zh-CN: 剩余时间, zh-TW: 剩餘時間), no English fallback. Parity check 5239 leaves per locale, green. ESLint clean; `npm run build` clean.
-
-- **Prominent sponsor banner at the top of Settings → General (the default landing tab)** — Full-width gradient panel with a heart icon, a one-line independence framing, and a "View supporters" CTA linking to `bambuddy.cool/sponsors.html?from=app-settings` so Matomo can split-track this surface against the website's own positions. Motivation lives in `bambuddy-install-base-2026-06-20.md`: re-baselining install count via the ghcr.io pull counter (~10k pulls/day rising) puts active deployments around 8-12k, and at 8 sponsors (per [[sponsor-portal]]) that's 0.08% conversion — roughly an order of magnitude under industry-benchmark for OSS with visible CTA. Matomo data confirms it's a discovery gap rather than a value-prop gap: `/sponsors.html` reaches only 1.18% of website visitors over the May 21 - Jun 19 window even though `/installation.html` reaches 29%, and the sponsors page itself converts fine when reached (70 s dwell, 53% bounce). The banner targets the in-app surface where the existing 9,140 monthly installation-page visitors actually live after they finish installing. Three new `sponsors.*` i18n keys (`sectionTitle`, `tagline`, `viewSupporters`) translated into all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Same release also ships a post-install ribbon between Quick Install and System Requirements on the `bambuddy-website` repo's `installation.html`, plus a `?from=install-bottom` tracking param on the existing bottom CTA so the two website positions are A/B-comparable in Matomo from day one.
-
-- **In-app sponsor-toast triggered at earned milestones (Prints / Cost / Archives / Anniversary / Version-update)** — Companion piece to the prominent sponsor banner that shipped earlier in this release (Settings → General full-width gradient panel). Banner gives passive every-visit visibility on a single page; the toast adds opt-out-able active visibility at moments where the user has just earned something with Bambuddy. **Motivation: 0.08% conversion gap.** Re-baselining the install base from the ghcr.io pull counter (~10,000 pulls/day rising, captured in `bambuddy-install-base-2026-06-20.md`) places active installs around 8,000-12,000 — at 8 current sponsors (per [[sponsor-portal]]) that's a 0.08% conversion rate, 6-25× under industry-benchmark for OSS with visible CTA. Matomo data over the May 21 - Jun 19 window shows only 1.18% of website visitors reach `/sponsors.html` despite 29% hitting `/installation.html` — the ask was discoverable on the marketing site but invisible inside the running app where users actually live. **Trigger families (5).** **Prints**: completed prints reach 100 / 500 / 1000 / 2500 / 5000. **Cost**: cumulative tracked filament-plus-energy cost crosses €100 / €500 / €1000 (currency-agnostic threshold — the frontend renders with the user's configured currency symbol). **Archives**: 50 / 250 / 1000 print archives saved. **Anniversary**: 1 year from the user's `created_at` (auth-enabled), or `MIN(users.created_at)` as the install-anchor (auth-disabled, see below). **Version-update**: soft fallback that fires once after a major-version bump, re-armable on each subsequent bump. **Priority order.** When multiple families are eligible at once, the service picks in this order: anniversary → prints → archives → cost → version-update — most emotional / earned first; version-update is the unobtrusive fallback. **14-day cooldown across all families** so an active power-week with stacked milestones never triggers more than once. Backed by a single `last_shown_at` column on the per-user state row. **Auth-disabled mode is first-class, not an afterthought.** Roughly 60-70% of installs run with auth disabled (single-user home setups — exactly the local-first cohort that "Bambuddy stays free because people support it" lands hardest with). Rather than ship a half-feature for them, the state schema uses a `user_id NULLABLE` column: in auth-enabled mode there's one row per real user; in auth-disabled mode there's a single NULL-keyed install-default row. The service evaluates exactly one code path that branches at the SQL `WHERE` level (`column IS NULL` vs `column = X`), no doubled storage logic, no duplicated trigger code. Counter queries for prints / cost / archives use `print_log.created_by_id IS NULL` for the install-default count. **Backend.** New `SponsorToastState` model (`backend/app/models/sponsor_toast_state.py`) with columns `user_id` (nullable FK with `ON DELETE CASCADE` so a deleted user takes their toast state with them), `last_shown_at`, `milestones_seen` (Text storing a JSON-serialised `list[str]` of fired milestone keys for SQLite/Postgres uniformity), `last_seen_version`, plus standard `created_at`/`updated_at` timestamps. UNIQUE constraint on `user_id` so there can be at most one row per user (or exactly one NULL-keyed row). The table is created via `Base.metadata.create_all()` at init — no explicit migration in `run_migrations()` needed since this is a brand-new table, not an ALTER on an existing one. **Service.** `backend/app/services/sponsor_prompt.py` with two public entry points: `evaluate(db, user_id_or_None) -> Trigger | None` walks the five checks in priority order, returns the first eligible one or None; `dismiss(db, user_id_or_None, milestone)` anchors the 14-day cooldown and either appends the milestone to `milestones_seen` (one-shot families) or just bumps `last_seen_version` (version-update is re-armable). State row is created lazily on first access so no migration seed is required. Print-milestone selection picks the LARGEST unseen threshold the user has crossed — a user who reaches 600 prints with no prior toasts gets prints-500 (not prints-100), so the relevant milestone fires; if they've already seen prints-500, they'd fall through to prints-100 next time the cooldown lifts. Cost path sums `print_log.cost + print_log.energy_cost` so the threshold reflects total spend Bambuddy has tracked, not just material. **Routes.** `GET /api/v1/sponsor-prompt/check` returns `{show: false}` or `{show: true, milestone, family, threshold, payload}`; `POST /api/v1/sponsor-prompt/dismiss` takes `{milestone: string}` and returns 204. Both gated with `Permission.SETTINGS_READ` via `RequirePermissionIfAuthEnabled` — every authenticated user has this, and auth-disabled installs hit them with `current_user = None` and the service handles that as the install-default row. **Frontend hook.** New `useSponsorPrompt(currencyCode)` hook (`frontend/src/hooks/useSponsorPrompt.ts`) fires once per browser session after auth resolves: checks `sessionStorage['sponsorPromptShown']` to avoid double-firing on a single session's mount/unmount cycles (Layout re-renders, navigation, etc.), then calls `sponsorPromptApi.check()`. If a trigger comes back, builds the localised message via the new `sponsors.toast*` keys and displays a persistent toast with a "View supporters" CTA linking to `https://bambuddy.cool/sponsors.html?from=app-toast-{milestone}` — every milestone gets its own tracking parameter so Matomo can split-test which trigger families drive the most conversion. Click on the CTA fires `sponsorPromptApi.dismiss(milestone)` to anchor the cooldown server-side and closes the toast. The hook is wired into `Layout.tsx` (which sits inside `` so auth has already resolved) and pulls `settings.currency` from the existing settings useQuery — no duplicate fetch. **Toast extension.** Existing `ToastContext` extended with optional `action: { label, href, onClick }` on `showPersistentToast`. The non-dispatch toast renderer gets a new branch: if `action` is present, render an inline `` styled as a small bambu-green pill before the dismiss-X. Click on the action fires its `onClick` (used by the sponsor hook to call dismiss) and closes the toast. Existing showToast / showPersistentToast call sites are unaffected — `action` is optional, omitting it gives the previous icon + message + X behaviour exactly. **i18n.** 5 templated keys in the existing `sponsors.*` namespace (`toastPrints` `{{count}}` / `toastCost` `{{total}}` / `toastArchives` `{{count}}` / `toastAnniversary` / `toastVersionUpdate` `{{version}}`) — fewer raw strings than naive per-milestone (5 × 5 + 3 + 3 + 1 + 1 = 25) but emotionally equivalent because i18next interpolates the count at render time. Real 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 5214 leaves per locale. Cost messages are written so the currency symbol can be prepended client-side (`{{total}}` already includes the symbol) — works for USD, EUR, GBP, JPY, etc., the existing `getCurrencySymbol` util returns the right glyph from `settings.currency`. **What this does NOT do.** Provide an in-app opt-out toggle — the 14-day cooldown plus the "earned milestone" requirement means a typical user sees the toast 5-15 times per year, which we picked deliberately as the line between visible and naggy. If user feedback after the 2026-06-27 Matomo conversion check (see the install-base memory) shows the cadence is too aggressive we'll add a Settings → Notifications toggle then; shipping it now would dilute the "is this actually a problem worth fixing?" signal. Use plural-form i18n suffixes (`_one`, `_other`) on the count keys — the message templates are written so they read naturally at every count value (100 / 500 / 1000 are all plural in every locale, anniversary is hardcoded to "one year"), but languages with three+ plural forms (Russian, Polish, Arabic) would need this later if we ship those locales. Affect the existing Settings → General sponsor banner shipped earlier in this release — that's a passive every-visit surface and stays exactly as-is; the toast is the active milestone-based companion. Affect un-authenticated routes (login page, setup page, spoolbuddy kiosk, camera embeds) — the hook lives inside Layout which only renders inside ``. **Tests.** 24 new cases. 20 in `backend/tests/unit/test_sponsor_prompt_service.py` covering: empty-state no-fire (× 2), state row lazy creation, 14-day cooldown (within / past × 2), prints fires at 100 + picks-highest-unseen + skips-already-seen + failed-prints-don't-count (× 4), archives at 50, cost crosses 100 (counting only completed cost-bearing prints, not raw print count), anniversary at 370d vs 300d (× 2), version-update first-read silently anchors vs subsequent fires on bump (× 2), priority anniversary-beats-prints + prints-beats-archives (× 2), dismiss-adds-to-seen-and-anchors-cooldown + re-evaluation-returns-None, version-update-dismiss-updates-version-not-seen-list (× 2), auth-disabled uses install-anchor + null-keyed-counters-isolated-from-per-user (× 2). 4 in `backend/tests/integration/test_sponsor_prompt_api.py` covering: `/check` returns `{show: false}` on empty install, `/dismiss` 422 on missing milestone, `/dismiss` 204 on success, check-then-dismiss-then-recheck-is-silent (cooldown anchors even when the original check returned `show: false`). Frontend: existing `ToastContext.test.tsx`, `Layout.test.tsx`, `SettingsPage.test.tsx` all green (74/74 — the action-prop extension is additive on an optional field, so existing toast tests with no action keep their previous expectations). Full backend `pytest -n 30` 6250/6250 in 64 s; ruff clean (4 import-order auto-fixes applied); ESLint clean; `npm run build` clean (1.74 s); i18n parity 5214 × 11 green.
-
-- **File Manager: user-authored tags for cross-cutting file filtering (#1268, requested by @zumik3-del, seconded by @unLieb)** — Third and final piece of #1268, shipped alongside the recursive-search + markdown-description-panel changes below. Folders are the hierarchy (every file lives in exactly one); tags are the orthogonal labels ("toy", "kid-safe", "petg-only", "failed twice", "gift") and a single file can carry as many as the user wants. Reporter wanted to find "every toy regardless of which folder it lives in" — folders alone can't do that without forcing files into one bucket. **Catalog model.** New `library_tags` table (id, name, name_key UNIQUE = `LOWER(TRIM(name))`, timestamps) holds the global tag catalog — one set per install, not per-user (matches the Locations PR #1505 from earlier in 0.2.5b1). `name_key` UNIQUE on a normalised key collapses "Toys" / "toys" / " TOYS " into a single row so users can't accidentally fragment the tag space by typing variations. New `library_file_tags(file_id, tag_id)` composite-PK association table with `ON DELETE CASCADE` on both sides — deleting a tag drops every chip from every file (files survive); deleting a file drops its tag links (catalog rows survive). Both tables auto-create via `Base.metadata.create_all()` at init — no explicit `run_migrations()` step needed since they're greenfield. **API.** New `/library/tags` router (`backend/app/api/routes/library_tags.py`) with `GET` (list + per-tag `file_count` projected via subquery; filtered by ownership for `LIBRARY_READ_OWN` users so chip counts match what they'd actually see), `POST` (create — strips whitespace, 409 on case-insensitive dup with both pre-check AND post-commit IntegrityError catch for race safety), `PATCH /{id}` (rename, same 409 rules, self-rename allowed via id-exclusion in the pre-check), `DELETE /{id}` (cascade), and `POST /library/tags/bulk-assign` for multi-file ops. Bulk-assign supports three actions: `add` (idempotent — re-applying doesn't 409, just no-ops for pre-existing pairs, count reports what actually changed), `remove`, and `replace` (strip everything currently on the listed files, then INSERT the new set — passing empty `tag_ids` with `replace` clears the file's tag set entirely). Per-file ownership enforced for `LIBRARY_UPDATE_OWN` users via a pre-filter on `file_ids` (silently drops files the caller can't update — same posture as `library_trash` bulk routes; the response counts reflect what actually happened so the UI can detect partial application). Unknown `file_ids` (race with a deleter, stale FE selection) are silently dropped instead of 404'ing the whole call. **`list_files` extension.** New `tag_ids: list[int]` query param on the existing `/library/files` route — repeated `?tag_ids=N&tag_ids=M` style. AND semantics: JOIN the association, `GROUP BY file.id HAVING COUNT(DISTINCT tag_id) = len(tag_ids)`, portable across SQLite and Postgres. Per the design discussion, the tag filter **intentionally bypasses folder scoping** (`folder_id` / `project_id` / `include_root` / `recursive` are all skipped while `tag_ids` is non-empty) — the whole point of tags is cross-cutting "every file matching these labels regardless of where it lives". Every file in every listing response now carries `tags: list[{id, name}]` via `selectinload(LibraryFile.tags)` so chip rendering on the FE is N+1-free. **Frontend — `LibraryTagsModal`.** Catalog CRUD modal opened from the File Manager toolbar's new **Tags** button, max-w-4xl wide so multi-language subtitles don't wrap. Table with name + file count + rename/delete actions; row-click pushes the tag into the active filter and closes the modal. Delete confirm-dialog warns specifically when `file_count > 0` ("This tag is on N file(s). Deleting removes the chip from all of them; files themselves are untouched."). Same Esc / backdrop / mid-mutation guard shape as `LocationsModal`. **Frontend — `BulkTagsPickerModal`.** Opens from the File Manager's multi-select toolbar (new **Tag** button between Move and Delete). Add/Remove radio at the top, scrollable checkbox list of catalog tags, inline "create new tag" affordance disabled on case-insensitive dup against the existing list, Apply button disabled until ≥1 tag is selected. The `replace` action is exposed in the API but deliberately NOT in the UI — arbitrary multi-file replace is destructive and confusing; future bulk-edit screen can opt in later. **Frontend — `FileManagerPage` integration.** New `selectedTagIds: number[]` state, sorted into the `useQuery` key so the cache hits are stable regardless of toggle order. Tag catalog shared with the modals via `['library-tags']` query key (extracted to `frontend/src/utils/libraryTagsQuery.ts` to satisfy Vite's react-refresh rule that component files export only components). `useEffect` prunes `selectedTagIds` when a tag is deleted from the catalog so the filter never strands on a phantom id. **Filter rail** above the file list lists EVERY catalog tag as a togglable chip — inactive chips are outlined and muted, active chips are filled bambu-green with an X, click toggles. "Clear all" appears only when ≥1 tag is active. Hidden entirely when the catalog is empty so fresh installs don't see a stray bar. **List view** gets a dedicated **Tags** column at `minmax(0, 200px)` between Prints and Actions — placed after the existing data attributes since tags are a "file attribute". Empty state shows a `-` to keep the column shape consistent. **Grid view** chips render below the metadata block in each FileCard. Chip clicks in both views push to `selectedTagIds`; click propagation is stopped so a chip click doesn't toggle the file's selection state. **Type safety.** `LibraryFileListItem.tags?: LibraryTagSummary[]` is OPTIONAL even though the backend always emits an empty array, because legacy msw mocks in pre-existing tests (FileManagerPage / FileManagerExternalFolder) construct partial file shapes without the field — without the `?` the renderer crashed on `.length`. Read sites use `file.tags ?? []` and the `!.` non-null assertion only inside the inner `&&` guard. **Dependencies.** Zero new deps. The whole tag UI reuses `lucide-react`'s `Tag` icon, existing button/modal primitives, and `@tanstack/react-query` already in the bundle. Bundle size unchanged from the previous 0.2.5b1 baseline (7,876 KB raw / 2,122 KB gzip). **Tests.** 15 backend integration cases in `backend/tests/integration/test_library_tags_api.py` — CRUD: create + list, strip-whitespace, case-insensitive dup 409 across "Toys"/"toys"/"TOYS"/" ToYs ", rename, rename-collision 409, self-rename allowed, delete cascades associations but keeps files, delete-unknown 404. Bulk: add idempotency (second call adds 0, file_count stays 1), remove drops only listed tags (peer tag stays), replace-with-empty clears, unknown file ids silently skipped, invalid action 422. Filter: AND across two tags returns only the intersection file, tag filter overrides folder_id (file from another folder still appears when the tag matches), file listing includes the tags array. 15/15 green plus 102/102 across `test_library_api.py` + `test_library_trash_api.py` (no regression). 8 frontend cases — 4 in `LibraryTagsModal.test.tsx` (renders + count, create flow PATCHes correctly, row click → onPickTag + close, in-use delete warning), 4 in `BulkTagsPickerModal.test.tsx` (lists tags, check + Add calls bulkAssign with action='add' and the right file/tag arrays, Remove radio + Apply uses action='remove', Apply disabled when no tag selected). Full vitest run: 2249/2249 across 170 test files. Full backend `pytest -n 30`: 6341/6341. **i18n.** 37 new keys under `fileManager.tags.*` namespace (modal title/subtitle, manage/manageTitle, add/edit, name/fileCount, empty/noMatches, createPlaceholder/createButton, nameRequired, searchPlaceholder, CRUD success/failure toasts, applyAdd/applyRemove + their success messages, actionAdd/actionRemove radio labels, tagAction button, bulkTitle, bulkTooltip, noPermission, filterLabel, clearAll, confirmDelete + the in-use variant, editAria/deleteAria). Translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5292 leaves per locale, no English fallback (the `defaultValue: "..."` shortcut from a first-draft modal was removed precisely so the parity check would fail loudly if any locale missed a key). **Permissions.** Catalog mutations require `LIBRARY_UPDATE_ALL` (the catalog is global — ownership-aware update isn't meaningful for a row no user owns). Bulk-assign uses the existing `LIBRARY_UPDATE_ALL`/`LIBRARY_UPDATE_OWN` ownership pair. GET uses `LIBRARY_READ_*` with the file-count projection narrowed for `*_OWN` callers. No new permission constants, no new RBAC migration. **Out of scope (deferred to v2 if asked).** Tag colors / icons (label-only chips per design decision #1), tags on `print_archives` rows (different mental model — archives are completed prints), auto-tags derived from 3MF metadata categories (kept user-authored per design decision #4), import/export of the tag set, tag-filter intersected with folder scoping (the design call was that cross-cutting filter overrides folder selection — adding an "AND folder" toggle would need separate UX work). **Closes #1268** alongside the recursive-search and markdown-description-panel pieces below — all three deliverables in this issue ship in the same minor.
-
-- **File Manager: recursive search and per-folder markdown description panel (#1268, requested by @zumik3-del, second by @unLieb)** — Two of the three asks bundled in #1268; the third (tags) is gated on the community-interest check Martin posted there. **(1) Recursive search inside the selected folder.** Until now, picking "Toys" in the sidebar and typing `robot` only found files in `Toys/` itself — anything under `Toys/Cars/` or `Toys/Cars/Race/` was invisible until the user manually drilled in. The page's client-side filter was running over a server-narrowed list (`/library/files?folder_id=X` is strict equality on `folder_id`), so search couldn't see what the listing didn't load. New `recursive=true` query param on `/library/files` walks the `library_folders.parent_id` tree via a recursive CTE rooted at the requested `folder_id` and returns every descendant folder's files in one round-trip. Recursive CTEs work on both SQLite (≥3.8.3, shipped 2014 — Bambuddy's floor is well above that) and Postgres without dialect branching. Default off so the existing folder-browsing call sites (Project / Archive detail pages, the FE's no-search case) keep their narrow single-folder semantics — only the FE's search bar opts in, and only when both a folder is selected AND `searchQuery.trim()` is non-empty. A small "Including subfolders" hint renders under the search input when the recursive request is active so the user understands why a file from two folders away showed up. **(2) Per-folder markdown description panel.** New endpoint `GET /library/folders/{folder_id}/readme` reads the first `.md` file in the folder and returns `{filename, content, truncated}`. Selection prefers `README.md` / `readme.md` / `description.md` (case-insensitive — picked via `func.lower(filename) LIKE '%.md'` filter + an in-Python stem-preference sort), falls back to the alphabetically-first `*.md` otherwise. 404 when no markdown file is present so the FE can hide the side panel — non-users pay no UI cost. Bytes are clipped at 512 KiB (`_README_BYTES_CAP`) with a `truncated` flag so the panel can warn the reader; UTF-8 decode uses `errors="replace"` so one bad byte never blanks the panel. New `FolderReadmePanel.tsx` component fetches the README on folder-select, renders it via `react-markdown@9` + `remark-gfm@4` (tables / strikethrough / task lists), collapsible (default expanded), max-height 24rem with internal scroll. react-markdown 9 doesn't render raw HTML by default — XSS safe without dompurify. Links open in a new tab with `rel="noopener noreferrer"`. Tailwind has no typography plugin in this project so per-element components map h1/h2/h3/p/ul/ol/code/blockquote/table/etc. to explicit utility classes that match the rest of the app's look. **Both ask 1 and 2 ship as one PR** because they share scope (file-manager UX), the same reporter, and the same review surface; ask 3 (tags) is held back as gated on the public interest signal Martin requested in his comment ("If you'd find this feature useful, please give this issue a thumbs up"). **Backend.** `list_files` route at `backend/app/api/routes/library.py:1729+` gains the `recursive: bool = False` param + the recursive-CTE branch. New `get_folder_readme` route at `:1042+` with `_README_BYTES_CAP` constant + `_README_PREFERRED_STEMS` selection tuple. New `FolderReadmeResponse` schema in `backend/app/schemas/library.py:66+`. **Frontend.** `api.getLibraryFiles` at `frontend/src/api/client.ts:5785+` gains the `recursive = false` parameter; `api.getLibraryFolderReadme` is the matching helper for the new endpoint. `FileManagerPage.tsx` derives `searchExpandsSubfolders` from `selectedFolderId !== null && searchQuery.trim().length > 0` and threads it into both the `useQuery` key (so toggling search refetches with the new scope) and the API call. The new `FolderReadmePanel` mounts above the file list when `selectedFolderId !== null`. **Dependencies.** `react-markdown ^9` + `remark-gfm ^4` added to `frontend/package.json` (~30 KB gzipped — single use-site for now, but reusable for any future markdown surface — print-archive notes, custom-field docs, etc.). No new backend dependency. **Tests.** 6 backend integration cases in `backend/tests/integration/test_library_api.py` pin the contract: `recursive=true` walks a three-level tree and returns files from all levels but NOT a sibling unrelated branch; `recursive=true` without `folder_id` is a no-op (the existing `include_root` branch still handles scoping); README endpoint returns the first .md with the correct on-disk content; README endpoint prefers `README.md` over `notes.md` even when `notes.md` is inserted FIRST and `readme.md` is lowercase; 404 when the folder has no .md; 404 when the folder doesn't exist. 3 frontend cases in `src/__tests__/components/FolderReadmePanel.test.tsx` cover: 404 hides the panel (no leaked chrome), markdown content renders via `findByRole('heading')`, truncated flag surfaces a chip. Full backend `pytest -n 30` 6326/6326 green; frontend vitest 1094/1094 component cases green; ruff clean; `npm run build` clean. **i18n.** 2 new keys — `fileManager.searchSubfoldersHint` (the small under-search caption) + `fileManager.readme.truncated` (the chip label when the markdown was clipped). Translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity check 5255 leaves per locale, no English fallback. **Scope.** No new permission — both endpoints reuse the existing `LIBRARY_READ_ALL` / `LIBRARY_READ_OWN` ownership-aware permission pair (so a viewer-tier user with `read_own` only sees their own files in recursive listings + can only request the README of folders containing their own files). No DB migration. The recursive CTE is a single SQL query — no N+1, no per-folder round-trip, scales to deeply-nested model libraries.
-
-- **By-tag spool lookup, readable with a Manage-Inventory API key (#1700 closing #1663, reported + contributed by @bambuman)** — Companion to the QR-code-API-key flow below: gives @bambuman's BambuMan NFC inventory app — and any future scanner-driven Bambuddy integration — a way to dedupe a spool scan with a single, narrowly-scoped API key. **New endpoint:** `GET /inventory/spools/by-tag?tray_uuid=…&tag_uid=…&include_archived=false`. `tray_uuid` is the primary identifier (it's the same 32-char hex the AMS reports over MQTT, so the scan can match a spool that's already linked to the printer), `tag_uid` is the fallback. At least one must be supplied (400 otherwise); 404 when nothing matches. Both values are passed through `normalize_tray_uuid` / `normalize_tag_uid` from `backend/app/utils/tag_normalization.py` — lowercase / colon / dash separators all match the stored uppercase hex, mirroring the existing `link_tag` route's `func.upper(column) == value` comparison so SQLite and Postgres behave identically. Archived spools are excluded by default, opt in via `include_archived=true`. **Why this isn't on the existing `/inventory/spools` list endpoint:** that one is purely advisory — it returns every spool the caller is allowed to see, no auth narrowing possible. The contributor's NFC app would have had to pull the whole inventory to check whether a freshly-scanned tag already existed, which both required the broader **Read Status** scope (an API key with **Manage Inventory** alone — the documented kiosk/inventory-write scope — couldn't list spools) and grew O(n) with the user's spool count. By-tag lookup is O(1) and the narrower scope rule below means the Manage-Inventory key the app already needs to *create* a spool is also enough to *check whether one exists* before creating. **Scope shape (per-endpoint, NOT a global mapping change):** `RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_UPDATE)` — INVENTORY_READ is satisfied by `can_read_status` (read-status keys), INVENTORY_UPDATE by `can_manage_inventory` (manage-inventory keys), and `_check_apikey_permissions(..., require_any=True)` enforces that at least one mapped flag is set (the GHSA-r2qv-8222-hqg3 fail-closed rule). Listing all spools (`/inventory/spools`) and fetching by id (`/inventory/spools/{id}`) still require **Read Status** unchanged — only this one endpoint accepts either scope. The first iteration of the PR widened the global `_APIKEY_SCOPE_BY_PERMISSION` to a tuple, which would have promoted ~21 inventory-read endpoints to also accept manage-inventory keys; review caught that the global shape was wider than the ask and the contributor revised to the per-endpoint dependency. The drift-detection RBAC scope-introspection tests stay untouched because the global table didn't change. **Route ordering:** the new `/spools/by-tag` registers at `inventory.py:1184` *before* the existing `/spools/{spool_id}` at `:1227`, so FastAPI's first-match wins and the literal `by-tag` path never collides with the `int spool_id` route (pinned by `test_does_not_collide_with_spool_id_route`). **Tests:** 13 integration cases in `backend/tests/integration/test_spool_by_tag_lookup.py` — match by tray_uuid, match by tag_uid, normalisation of messy input, tray_uuid-preferred-when-both-given, tray_uuid-miss falls through to tag_uid (not 404), no-id → 400, non-hex → 400, no-match → 404, archived-excluded-by-default + include-archived opt-in, route-collision regression, plus three API-key scope cases that pin the new dependency (manage-inventory key reads, read-status key reads, key without either inventory scope gets 403). 13/13 green plus the 48 existing route-auth-coverage + RBAC tests still green (the `require_` substring pattern already catches `require_any_permission_if_auth_enabled..checker` — no allowlist edit needed). Ruff clean. **Companion docs (maziggy/bambuddy-wiki#42):** `docs/reference/api.md` gains a new **Spool Inventory** section documenting the endpoint contract; `docs/features/api-keys.md` adds the by-tag row to the Common Endpoints table and a "Manage Inventory keys can look up spools by tag" note. No DB migration, no schema change, no frontend change.
-
-- **QR code on API-key creation that encodes server URL + key together (#1677, contributed by @bambuman)** — The "API Key Created Successfully" panel gets a new **QR code** button next to **Dismiss**. Clicking it opens a modal showing a single QR encoding the Bambuddy base URL and the freshly-created API key together, so a mobile client (e.g. the contributor's BambuMan NFC inventory app, or any future Bambuddy-aware app) can scan once to configure both — no copy-paste of the long, shown-only-once secret. **Payload contract (versioned):** `bambuddy://config?v=1&url=&key=`. `v=1` first so future bumps to `v=2` have a clean deprecation path; both values URL-encoded so reserved characters in either don't corrupt the parse. The builder lives in `frontend/src/utils/apiKeyQr.ts` exporting `buildApiKeyQrPayload()` + `API_KEY_QR_VERSION` so any future mobile-side parser has a stable shared constant to anchor against. **`baseUrl` source:** prefers the configured **External URL** setting (Settings → Network), falling back to `window.location.origin` if not set, so the encoded address is reachable from a phone behind a reverse proxy / Docker host. The fallback's failure mode (admin on `http://localhost:8000` without External URL configured → phone can't reach the encoded URL) is unavoidable without exposing a network probe; the warning text in the modal cautions the user generally. **Security posture:** the QR is generated **client-side from the in-memory `createdAPIKey`** React state — the key is never persisted, never re-fetched (keys are stored hashed at `/api/keys` POST and returned in plaintext exactly once), and never round-trips to the server. No download button (intentional contrast with the existing `QRCodeModal.tsx`, which encodes a public archive URL and does offer download) so the secret can't be saved to disk via the browser's download manager. The "Dismiss" handler now clears both `showApiKeyQR` and `createdAPIKey` so closing the panel scrubs the plaintext from React state. Modal closes on Escape and backdrop click; an amber warning under the QR reminds the user not to screenshot or share. **Component:** new `frontend/src/components/ApiKeyQRCodeModal.tsx` using `qrcode.react`'s `QRCodeSVG` at 256 px (renders Version 5 / 6 territory for the typical ~120-character payload, comfortably below the alphanumeric capacity). **Dependency:** `qrcode.react ^4.2.0` added to `frontend/package.json` (+21 KB raw / ~9 KB gzip to the bundle). Existing `frontend/src/components/QRCodeModal.tsx` is untouched — different purpose (server-rendered PNG for archive deeplinks), different component, no collision. **Tests:** `frontend/src/__tests__/utils/apiKeyQr.test.ts` pins the contract — scheme + `v=` first, exact encoding of `https://printer.local` + `bb_abc123` byte-for-byte, special-character round-trip (`+`, `/`, `=`, `&`, spaces), explicit assertion that the raw unencoded key never leaks into the payload, and a `URLSearchParams` round-trip that re-parses `v` / `url` / `key` back out and asserts equality with the inputs. 4/4 green. **i18n:** 4 new keys in the `settings.*` namespace (`apiKeyQrButton`, `apiKeyQrTitle`, `apiKeyQrCaption`, `apiKeyQrWarning`); full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity check green. ESLint clean; `npm run build` clean (7,603 kB raw, +21 kB vs dev). No backend change, no permission change, no DB migration.
-
-- **Centralised sidebar layout + per-page hide toggles (#1673, contributed by @EdwardChamberlain)** — Sidebar item ordering and visibility move from inline `Layout.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_` 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=` (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.
-
-- **Per-VP "G-code injection" toggle for Studio Send / FTP uploads (#1516, contributed by @phieb)** — Queue-mode Virtual Printers gain a per-VP opt-in toggle that applies the Settings → G-code Snippets per-model start/end snippets to every job that lands via the VP — Bambu Studio's "Send", OrcaSlicer's "Print Plate", the VP's own FTP upload path. Before this change the snippets were only applied to items queued through the PrintModal's "Inject auto-print G-code" checkbox; VP-incoming jobs silently bypassed injection regardless of how the snippets were configured. **Default off so upgraders don't silently start injecting**: existing `gcode_snippets` installs keep their previous behaviour until the per-VP toggle is explicitly enabled. When on, the scheduler still no-ops unless `gcode_snippets` are configured for the target printer model, so the effective semantics are "inject when enabled AND snippets exist." **DB column:** new `virtual_printers.gcode_injection BOOLEAN DEFAULT FALSE` with a branched `is_sqlite()` migration (SQLite `DEFAULT 0` / Postgres `DEFAULT FALSE`) matching the `queue_force_color_match` / `tailscale_disabled` precedent. **Multi-plate stamping:** the flag is set on every plate's `PrintQueueItem` inside the per-plate loop introduced by #1697 / #1188, so a multi-plate "Send all" upload now gets snippets injected on each plate consistently — the original PR only stamped the first plate; the merge resolution wove the flag into the loop. **Live-toggle correctness:** the `_sync_from_db_locked` change detector now compares `instance.gcode_injection != vp.gcode_injection`, so toggling the value in the UI triggers a VP restart instead of letting the in-memory instance keep the stale flag and silently propagate it onto every subsequent upload — same shape as the #1552 family. Backed by a dedicated `test_sync_from_db_restarts_on_gcode_injection_toggle`. **UI:** new toggle on `VirtualPrinterCard.tsx` (queue mode only — the toggle is hidden in archive/review/proxy modes since the feature is queue-specific), with the standard `updateMutation` save-on-click + toast on success, plus the `pendingAction='gcodeInjection'` opacity dim during the round-trip. **PrintModal hardening:** when "Inject auto-print G-code" is ticked on a reprint at quantity > 1, the modal now routes ALL copies through the queue (not just copies 2..N) so the scheduler injects every dispatch — see the separate reprint-quantity entry below for the full motivation. A new `useEffect` clears the stale `gcodeInjection` state if the user ticks the box at quantity 2, then drops back to quantity 1 — the checkbox hides at that point and the state must follow, otherwise the immediate-reprint path would silently bypass injection. **Diagnostics:** the resolved start/end snippets (with `{placeholder}` substitution already applied) are logged at DEBUG so any "snippet didn't run" report can be traced from a log bundle. **i18n:** new `virtualPrinter.gcodeInjection.title` + `description` keys translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); parity check 5188 leaves per locale, no English fallback. **Tests:** 2 new unit cases in `test_virtual_printer.py` (queue items opt in / out based on the VP flag), 2 new integration cases in `test_virtual_printer_api.py` (create defaults to false, PUT round-trips the value), 1 new sync-restart case, plus updates to `_make_db_vp` so the change-detector test fixture carries an explicit `False` rather than relying on `MagicMock` truthiness. 2 new PrintModal vitest cases pin the reprint dispatch matrix (injection ON queues all copies and dispatches none immediately; injection OFF keeps the immediate first copy and queues the rest). Full backend pytest 6167/6167; full frontend vitest 2154/2154; ruff clean; `npm run build` clean.
-
-- **Heater history (nozzle / bed / chamber) tracked + charted, matching AMS sensor history** — Bambuddy already logged AMS humidity / temperature every 5 minutes and exposed them in a per-AMS history modal; the printer-side heaters (nozzle, optional second nozzle on H2D, bed, chamber) had no equivalent surface. New per-printer recorder logs heater readings every 60 s (heaters move faster than AMS humidity) into a new `printer_sensor_history` table — long format per `(printer_id, sensor_kind, value, target, recorded_at)` with `sensor_kind ∈ {nozzle, nozzle_2, bed, chamber}` so model variants (single vs dual nozzle, sensor-only X1C/P2S chamber vs heater-equipped X1E/H2D chamber) all fit without a wide-row migration when new sensors get added later. **Recorder source.** Pulls from `state.temperatures` (already normalised across model field-aliases like `nozzle_temper`, `left_nozzle_temper`, `right_nozzle_temper`, `chamber_temper` by the MQTT parser) rather than re-parsing `raw_data`, so cross-model coverage is free. Skips disconnected printers; absent sensors (no chamber on A1 / A1 Mini) just don't produce rows for that kind. **Retention.** New `printer_sensor_history_retention_days` setting (default 30, mirroring the existing `ams_history_retention_days` knob); periodic cleanup fires once every ~24 h within the recorder loop and trims rows older than the configured cutoff. **API.** `GET /printer-sensor-history/{printer_id}?hours=<1-168>&kinds=` returns one series per requested kind with `data: [{recorded_at, value, target}, ...]` + min / max / avg stats per series. `DELETE /printer-sensor-history/{printer_id}?days=` for explicit purge. Both gated behind the new explicit `PRINTER_SENSOR_HISTORY_READ` scope (separate from `AMS_HISTORY_READ` so admins can grant heater stats without granting humidity stats and vice versa — both default into the Operators + Viewers groups, mirroring the existing AMS gate). **UI surface — tiny chart icon on each heater tile.** Each heater tile in the printer card (nozzle / bed / chamber) gets a 10×10 px lucide `LineChart` icon button in its top-right corner. Click on the tile body still opens the existing target-temp control popover (unchanged); click on the icon opens the new `HeaterHistoryModal` with that kind pre-selected and `e.stopPropagation()` so the underlying control popover doesn't also fire. The read-only chamber tile (X1C / X1E / P2S — sensor-only, no M141 acceptance) gets the icon overlay too, so for the first time it has a useful interaction beyond just showing the reading. **Modal shape.** Same `var(--bg-*)` / `var(--text-*)` theme variables as `AMSHistoryModal` so it follows every background variant (neutral / warm / cool / oled / slate / forest), not just light vs dark. Kind toggle (`nozzle` / `nozzle_2` / `bed` / `chamber`, filtered by what's actually available on this printer) at top-left, time range (6h / 24h / 48h / 7d) at top-right, four stat cards (current with trend arrow + target value, average, min, max), and a recharts `LineChart` plotting current value as a solid line plus target as a dashed step-after line so you can see where the printer was tracking vs where it was asked to be. Empty / loading / error states all localised. **i18n.** 8 new keys in `printers.heaterHistory.*` (`title`, `openLabel`, `nozzle`, `nozzle2`, `bed`, `chamber`, `error`, `empty`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); parity check 5206 leaves per locale, no English fallback. **Tests.** 4 new backend cases in `test_printer_sensor_history.py` (per-sensor series + stats; `kinds` query filter; `hours` window clamp; DELETE removes only the targeted printer's old rows). 6 new frontend cases in `HeaterHistoryModal.test.tsx` (closed → renders nothing; open → title + printer name; current value from last point; switch kind with button; empty-series state; close button onClose). Full backend `pytest -n 30` 6226/6226 in 92 s; full frontend vitest 2176/2176; ruff clean; `npm run build` clean; ESLint clean.
-
-- **AMS Filament Backup status + control on the printer card** — New per-printer surface that mirrors BambuStudio's "AMS Filament Backup" checkbox (the per-AMS auto-switch to a second matching spool when one runs out). Until now Bambuddy had no read or write access to the printer-side backup state; the only way to change it was via the slicer or the printer's touchscreen, and Bambuddy's "Prefer lowest remaining filament" preference was ignorant of it (see the linked Fixed entry for #1766 — the two ship together). **Backend — parse the state.** New tri-state `PrinterState.ams_filament_backup: bool | None` populated from bit 18 of the top-level `print.cfg` hex string on every push_status (`bambu_mqtt.py::_process_message` ~line 1037). New module-level helper `parse_ams_filament_backup_from_cfg()` returns `None` on absent / non-hex / non-string input so old-protocol families (A1 / A1 Mini, which emit no `cfg`) preserve today's behaviour — the tri-state default applies the dispatcher's sort, never coerces to OFF, so A1 users see zero regression. Verified against OrcaSlicer source (`DeviceManager.cpp:4961` `SetAutoRefillEnabled(get_flag_bits(cfg, 18))`) and a live H2D ON/OFF capture during this work — the cfg flips exactly between `C0340FC219` (bit 18 set, ON) and `C0340BC219` (bit 18 clear, OFF), only the fifth nibble changing. **Backend — toggle.** New `POST /printers/{id}/ams-backup?enabled=` route gated on `Permission.PRINTERS_CONTROL` calls `client.set_ams_filament_backup(enabled)` which routes through `_set_print_option("auto_switch_filament", enabled)`. The MQTT payload shape `{"print": {"command": "print_option", "auto_switch_filament": , "sequence_id": "20000"}}` was verified by capturing BambuStudio's own command on the request topic with a temporary outbound diagnostic logger — single field at a time, never bundled with other `print_option` flags, so we never clobber other state. Optimistic local state update lives inside `_set_print_option` immediately after `_client.publish(...)`. **Hold-timer guard** (`_xcam_hold_start["print_option_auto_switch_filament"]`, 3 s window, mirrors the existing xcam pattern for spaghetti / first-layer detector settings): when the user just toggled via Bambuddy's badge, the next 1-2 push_status frames may still carry the printer's PRE-toggle cfg before the firmware reflects the change — without this gate the badge would flicker ON→OFF→ON on every toggle. The hold fires only when Bambuddy itself initiated the change; Studio-side or printer-display toggles propagate immediately. **Backend — inventory-remain endpoint.** New `GET /printers/{id}/inventory-remain` route exposes the same `Map` the dispatcher uses (via the existing `_build_inventory_remain_overrides` helper), so PrintModal's client-side "Prefer Lowest Remaining Filament" sort can apply the same two-tier ordering the backend would on dispatch. Internal AND Spoolman modes both work uniformly via the existing helper's mode branch — external / VT slots excluded, negative grams clamped to `max(0.0, label - used)`. JSON-keyed-as-string convention so the wire format is clean; client coerces back to Number on receive. Permission: `Permission.PRINTERS_READ` (same as reading printer status). **REST + WS response surface.** `printer_state_to_dict` and the `PrinterStatusResponse` Pydantic schema both extended with the new field; the printer's REST `/printers/{id}` response carries `ams_filament_backup`. `state.ams_filament_backup` added to the `status_key` dedup tuple in `main.py:1101` so backup toggles trigger an immediate WS broadcast and clients see live state changes whether the toggle came from Bambuddy, BambuStudio, or the printer's touchscreen. **Frontend — printer card badge.** Small icon button in the "Filaments" section header on each printer card (`PrintersPage.tsx`), placed beside the section label so the printer-wide nature reads correctly (the cfg bit is one per printer, not per AMS unit — the original draft put it per-AMS row, which would have duplicated the same state on multi-AMS printers and looked confusing). Three states: ON = blue circular-arrow icon (`Repeat` from lucide-react) on `bg-blue-500/20`; OFF = dim icon on `bg-bambu-dark`; unknown (A1 family / no cfg yet) = "?" character on dim background, click disabled. Click on a known state toggles via the new endpoint, with optimistic update and success toast (`AMS Filament Backup enabled/disabled`). The mutation invalidates BOTH `'printerStatus'` (camelCase) and `'printer-status'` (kebab-case) cache keys — the codebase has both conventions in active use (`useFilamentMapping`-related hooks use kebab, everything else uses camelCase), so only hitting one would leave PrintModal showing stale backup state if the user toggled from the printer card while the modal was open. **i18n.** 5 new keys in the `printers.amsBackup.*` namespace (`titleOn`, `titleOff`, `titleUnknown`, `toastEnabled`, `toastDisabled`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. **Tests.** 12 new backend cases — `test_bambu_mqtt_cfg_parse.py` (parser × 13: real H2D ON/OFF captures, X1C short hex, lowercase, isolated bit-18 set / clear, every malformed shape returns None safely — note: 1 case is a parametrized invalid-input set of 7 sub-cases so the test file shows 13 reported cases) and `test_bambu_mqtt.py::TestAmsFilamentBackupHoldTimer` (× 3: stale push during hold ignored, push after hold applies, same-value push during hold no-op). Two PrinterState SimpleNamespace stubs in `test_printer_offline_notification.py` and `test_printer_manager_status_broadcast.py` extended with `ams_filament_backup=None` to match the new `status_key` field; full pytest confirms no other stub needed updating. **What this does NOT do.** Cover A1 / A1 Mini: those models emit no `cfg` field in push_status so the badge shows `?` and the dispatcher's sort applies as before. Once we identify the A1-specific field (waiting on a future Discord owner with a clean ON/OFF capture) we'll populate it via a model-specific path; until then the tri-state default keeps zero regression. Affect downstream consumers of PrinterState: `mqtt_relay`, webhook routes, and Home Assistant integration enumerate fields explicitly, so adding `ams_filament_backup` doesn't change what they emit. Full backend pytest 6217/6217; full frontend vitest 2170/2170; ruff clean; `npm run build` clean; ESLint clean.
-
-- **Sort File Manager folder tree by recent activity (#1770, requested by @Kingbuzz0)** — Until now the folder tree was always sorted alphabetically by name, both backend (`order_by(LibraryFolder.name)`) and frontend. The reporter — a user with a lot of nested cad / slicer directories — wanted "find folders that just got a new 3MF" without scrolling the whole alphabet. **What changed.** The folder sidebar header gains a small dropdown (**By name** / **By recent activity**) plus an asc / desc arrow button, sitting alongside the existing Collapse + Wrap toggles. Choice persists per-browser via `localStorage` (`library-folder-sort-field`, `library-folder-sort-direction`) so the preference survives reloads. **Activity semantics.** `latest_activity_at` per folder = `MAX(folder.updated_at, MAX(immediate-child file.updated_at))`. The DB had the data — `LibraryFile.updated_at` is `onupdate=func.now()` and `LibraryFolder.updated_at` the same — but `LibraryFolder.updated_at` alone only bumps on rename / move, not on file-add inside the folder, which is exactly the wrong signal for "did I just drop a new model in here." The aggregate fixes that. Recursion across subfolders is intentionally **NOT** computed — a deeply nested new 3MF bubbles its immediate parent, not every ancestor up to the root. This keeps the route a single `GROUP BY` rather than a recursive CTE, matching the existing file_counts subquery shape sibling at `library.py:746`. A future Tier 3 follow-up could add the recursive-CTE variant if anyone reports deeply-nested updates not bubbling far enough. **Backend.** New `latest_activity_at: datetime | None` field on `FolderResponse` and `FolderTreeItem` schemas. The `/folders` tree route picks up a sibling `func.max(LibraryFile.updated_at)` group-by alongside the existing file-count subquery; resolves the field per row. The `/folders/by-project/{id}` and `/folders/by-archive/{id}` routes collapse their per-row file-count subquery to fetch `count + max` in one trip (one extra column, zero extra round-trips). All 5 single-folder constructors (POST `/folders`, GET `/folders/{id}`, PUT `/folders/{id}`, POST `/folders/external`, the create flows) populate the field with `max(folder.updated_at, latest_file)` or fall back to `folder.updated_at` when there are no files, so the API surface is consistent across every route that returns a folder. **External folders.** `LibraryFile` rows are created for scanned external files too (`library.py:526`), so the MAX aggregate works on them — but the timestamp reflects when Bambuddy last *scanned / re-indexed* the file, not the filesystem mtime. For a NAS that gets new files added outside Bambuddy, the activity-sort lags until the next scan. Documented in the file-manager wiki page rather than papered over with `os.stat()` on every list call, which would stall the route on slow mounts. **Frontend.** A new recursive `sortedFolders` `useMemo` applies the comparator uniformly to top-level + every nested `children` level so sort order is consistent at every depth. Comparator falls back to name when activity timestamps tie or are both null, so an empty folder never elbows a recently-used one to a random place — empties go to the end of the activity bucket regardless of direction. Both the desktop sidebar render and the mobile selector dropdown consume `sortedFolders` so the order is identical across breakpoints. The single-folder `findFolder()` traversal and `selectedFolder` memo still operate on the unsorted `folders` because they index by ID — sort-order-independent. **Recursion safety.** The sort creates fresh object refs at every level on every memo invocation; the `FolderTreeItem` keys stay ID-based (`${folder.id}-${collapseFoldersByDefault ? 'c' : 'e'}`) so React reconciliation by ID preserves folder expansion state across sort flips. **i18n.** 3 new keys in `fileManager.*` (`folderSort`, `folderSortByName`, `folderSortByActivity`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity 5238 leaves per locale. **Tests.** 2 new backend integration cases in `test_library_api.py` (file-in-folder bubbles `latest_activity_at` to the file's timestamp, empty folder falls back to `folder.updated_at`). All 152 library + folder + trash + slice integration tests still pass; 51/51 FileManagerPage frontend tests still pass; 26/26 QueuePage tests still pass; `npm run build` clean; `ruff` clean; i18n parity green.
-
-- **Drag-reorder for grouped queue items + collapsed-batch no longer blocks adjacent rows** — Print queue batch grouping (multi-plate auto-batch + manual "Group as batch") shipped with an intentional v1 limitation: the batch parent row had no drag handle and wasn't registered with the SortableContext, so a batch was stuck at whatever position it was created at. Worse, a collapsed batch acted as an unmovable obstacle that adjacent standalone items couldn't drag cleanly past. The only workaround was to ungroup, reorder, and re-group. **Now.** Batch parents register in the SortableContext under a synthetic `batch-` string id, and the parent header carries a real `GripVertical` drag handle next to its existing checkbox + collapse chevron (gated by `queue:reorder` like the per-item handle). `handleDragEnd` learned to resolve both batch ids: when the dragged source is a batch, the moving block is all of its child items in their current order; when the drop target is a batch, the anchor is the batch's first child so the group lands immediately before it. Direction-aware insertion (dragging downward inserts after the target) uses the first moving id's index instead of the previously hard-coded single dragged id, which keeps multi-row and batch drags converging on the same insert math. Within-batch reorder of expanded children is unchanged — they're still individually `useSortable`-registered and the per-row drag handle still works. The `DragOverlay` gets a second branch for batch drags showing ` ( copies)` with a Package icon, so the user sees what they're moving. **i18n.** 2 new keys (`queue.batch.dragGroup` button title + `queue.dragGhost.batch` / `..._plural` overlay text), real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5235 leaves per locale, no English fallback. The FR plural is a legitimate cognate (French plural of "copie" is also "copies") so it's pinned in `FR_COGNATES` rather than fake-translated. **Build + tests.** Full QueuePage vitest 26/26 green; `npm run build` clean; ESLint clean; i18n parity green.
-
-- **Per-filament humidity threshold for auto-drying + alarms (#1605, requested by @thenewguy)** — The single global `ams_humidity_fair` knob (default 60 %) drove both the queue / ambient auto-drying trigger AND the hourly humidity alarm — fine for a homogeneous AMS, brutal for the multi-material print farms the reporter described (one AMS dedicated to PLA fine at ~20 %, another holding Nylon that wants <10 %, ASA somewhere in between). The drying RUN parameters were already per-filament (`drying_presets` JSON with PLA / PETG / TPU / ABS / ASA / PA / PC / PVA × n3f / n3s temp + hours), but the TRIGGER was a single int. **New setting** `ams_humidity_thresholds` — JSON map of filament type to threshold percent, with an explicit `"default"` key for unknown / unmapped types. Empty / unset → both consumers fall back to the existing `ams_humidity_fair` value so the upgrade is silent (no behaviour change until the user opens the editor). **Mixed-AMS resolution: most-restrictive wins**, matching the conservative-params strategy `_get_conservative_drying_params` already uses for temp / hours selection. A unit loaded with PLA (threshold 60) + Nylon (threshold 20) drying-triggers + alarms at 20 — same lowest-wins logic. Empty / unloaded tray slots contribute no constraint; an entirely empty AMS falls through to the `default` key. **Two consumer sites rewired in lockstep** via a single new `PrintScheduler.resolve_humidity_threshold(trays, thresholds, fallback)` static method: (1) `print_scheduler.py::_check_auto_drying` — the per-AMS humidity comparison that decides whether to start drying, whether to stop drying after the 30-minute minimum, and whether to skip a unit entirely; (2) `main.py` AMS sensor / alarm worker — the hourly notifier that fires `on_ams_humidity_high` / `on_ams_ht_humidity_high`. Both sites read the same `ams_humidity_thresholds` setting through the same resolver so a config change can never make the drying scheduler and the alarm path disagree about whether a given AMS is "too humid." **UI.** New table in Settings → Workflow → Auto-Drying, immediately below the existing Drying Presets table — same visual idiom (filament-type column on the left, value column on the right, italicised "Default (unknown types)" row at the top so the catchall is visible, eight default rows for PLA / PETG / TPU / ABS / ASA / PA / PC / PVA pre-filled from the user's current `ams_humidity_fair` so the editor starts in a sensible state). Numeric inputs use a **draft-on-edit / commit-on-blur** pattern (transient `humidityDrafts: Record` state per row) so intermediate strings like `""`, `"3"`, `"7"` aren't eaten by the `[5, 95]` clamp while the user is mid-typing. The clamp fires once on `onBlur` (and `Enter` blurs the input). Empty value on blur clears that row's override, letting it fall back to the default row (or `ams_humidity_fair` for the default row itself) — gives the user a no-mystery way to reset to default. The naive controlled-input pattern was caught mid-PR by Maziggy's typing test: typing `30` into a field showing `60` snapped to `5` between keystrokes because `parseInt("3") | clamp([5, 95])` resolved to `5` before the user finished typing the second digit. **i18n.** Four new keys in the `settings.*` namespace (`humidityThresholds`, `humidityThresholdsDescription`, `humidityThresholdCol`, `humidityThresholdDefault`) — real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5232 leaves per locale, no English fallback. **Tests.** 12 new backend cases in `test_scheduler_auto_drying.py::TestResolveHumidityThreshold` + `TestGetHumidityThresholds` — empty overrides → caller fallback, single known type uses override, mixed load picks lowest, unknown type uses the `default` key (not the caller fallback), empty tray slots skipped, all-empty trays use `default`, "PLA Basic" / "pla basic" both normalize to PLA, missing `tray_type` field is treated as empty, plus the four DB-loader cases (missing setting → empty, empty value → empty, invalid JSON → empty, valid JSON normalizes lowercase filament keys to uppercase but preserves the literal `default` key). One updated integration test in `test_settings_ui_preferences.py` pins the new field in the public `_UI_PREFERENCE_FIELDS` allowlist so a future regression that drops it would fail the field-set assertion. 1 new frontend case in `SettingsPage.test.tsx` exercises the editor render on the Workflow tab. **Setting is in the public `/ui-preferences` allowlist** because it's a non-sensitive integer map needed for badge-color rendering on spool / AMS pages without granting `SETTINGS_READ` (same rationale as `drying_presets` and `ams_humidity_fair`). **What this does NOT do.** Provide per-tray override (the resolver runs at the AMS-unit granularity because both the firmware drying command + the alarm fire per AMS, not per slot); auto-detect the user's loaded filaments and pre-populate (the editor pre-fills from `ams_humidity_fair` and lets the user opt in to overrides); add "keep AMS heater running during print" (a separate firmware-dependent ask in the same issue — needs feasibility check with the reporter before scoping). Full backend `pytest -n 30` 6270/6270 in 73 s; ruff clean; `npm run build` clean; ESLint clean; i18n parity green.
-
-- **Per-printer Maintenance Mode toggle (#1476, requested by @IndividualGhost1905 / Ferdi SEVER)** — Operator-flipped "out of service" state per printer, surfaced as a wrench icon + amber pill on the card and a checkbox in the Edit Printer dialog. Requested for three real-world scenarios that all share the same shape: (1) parallel Bambuddy installs (dev + prod, primary + warm spare) where the printer rejects concurrent MQTT clients except one, leaving the others in a flicker-online state burning CPU and network; (2) printers under repair / awaiting spare parts that shouldn't accept queue jobs but should remain visible on the dashboard so they aren't forgotten; (3) temporary suspension during maintenance work. **What was already there, what was missing.** The backend field `Printer.is_active: bool` has shipped since the initial Bambuddy release — toggling it via `PATCH /printers/{id}` already disconnects MQTT (`printer_manager.disconnect_printer` at `printers.py:366`), stops the printer from being eligible for queue dispatch (`print_scheduler.py:520, 1588`, `print_queue.py:383`), excludes it from model-based filament lookups (`printers.py:197`), excludes it from metrics + diagnostic snapshots + scheduled-backup runs (`metrics.py:105`, `diagnostic_snapshot.py:126`, `github_backup.py:333`, `maintenance.py:457`), and is already honoured by PrinterSelector (filtered with a "show inactive" override, greyed + "(inactive)" label when shown). All three of Ferdi's use cases were structurally supported by `is_active` from day one. **The missing piece was UI exposure.** `grep is_active` on `PrintersPage.tsx` returned zero hits — no menu item, no edit field, no toggle. The only way to flip it was a direct API call. This change adds the surfaces that should have been there all along. **Card UI — replacement, not addition.** Per Ferdi-conversation feedback, the maintenance state replaces the print-status / cover-image container rather than stacking above it, so card heights stay identical across the grid: in expanded mode the same `` header renders an amber panel (wrench icon + "In Maintenance" + subtitle + Exit button) where the cover + progress would normally be; in compact mode a single amber pill replaces the progress bar. The header connection pill is also swapped — instead of the red "Offline" pill (which would be misleading because the disconnect is deliberate) the card shows an amber "Maintenance" pill, and the "Run Diagnostic" CTA is suppressed (that's reserved for involuntary offline triage). HMS / Queue / Firmware status pills are still gated by `status?.connected` so they fall away naturally with the MQTT disconnect. **Three entry points.** (1) Printer card three-dot overflow menu — `Enter maintenance mode` / `Exit maintenance mode` with a wrench icon, adjacent to the Edit and Reconnect actions. (2) Exit button inside the in-card amber panel, so a user noticing the card from across the room can flip back without opening the menu. (3) Checkbox in the EditPrinterModal — `Maintenance mode` with the same subtitle as the help line, so the toggle is discoverable from the edit dialog too (the checkbox is the inverse of `is_active` because the user-facing concept is "is this in maintenance" not "is it active"). **Mid-print safety prompt.** Entering maintenance mode on a printer in `RUNNING` / `PAUSE` state triggers a confirmation dialog before the toggle fires — disconnecting MQTT mid-print stops progress tracking + completion notifications for the in-flight job, which is usually NOT what the operator wants (they probably meant "after this print finishes"). Idle / FINISH / FAILED states skip the dialog and toggle directly. **What this does NOT change.** No backend change (`is_active` was already wired everywhere); no new permission (uses existing `printers:update`); no behaviour change for any other consumer (queue dispatch, scheduler, metrics, picker, backup — all already honoured `is_active`). The card stays visible on the Printers page (greyed temps/controls/fans below the amber banner) so the printer doesn't disappear from the operator's mental map — Ferdi explicitly wanted to remember it's there. Doesn't auto-pause Smart Plug logic or notification providers (would be a sensible follow-up if Ferdi asks; out of scope here to keep the diff bounded to "expose the existing gate"). The scheduled-maintenance dashboard at `/maintenance` (interval-tracked rod-cleaning / lube / belt tasks via the existing `MaintenanceHistory` and `PrinterMaintenance` models) is conceptually adjacent but operationally distinct — the dashboard tracks "this printer is due for cleaning"; Maintenance Mode tracks "this printer is currently out of service." A future "perform maintenance task → optionally enter maintenance mode while you do it" link is the natural connection but isn't wired here. **i18n.** Twelve new keys under `printers.maintenance.*` (title / subtitle / pillLabel / exitButton / menuEnter / menuExit / toastEntered / toastExited / confirmMidPrintTitle / confirmMidPrintMessage / editFieldLabel / editFieldHelp) — real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5228 leaves per locale, no English fallback. **Tests.** 4 new cases in `PrintersPage.test.tsx::'maintenance mode (#1476)'`: amber status panel renders with Exit button (and the regular "No active job" / "Ready to print" copy is absent — confirms the swap, not a stacked render); header pill swaps to amber Maintenance and the diagnostic CTA is suppressed; clicking Exit issues a `PATCH /printers/{id}` with `is_active: true`; active printers never show the maintenance panel. Existing test fixture (`mockPrinters`) got an explicit `is_active: true` to keep the existing 56 tests green on the new render path. **Type:** `PrinterCreate.is_active?: boolean` added to the TypeScript surface so the field flows cleanly through the existing `api.updatePrinter` helper. **Build + checks.** Full PrintersPage vitest 60/60 green; `npm run build` clean; ESLint clean; i18n parity 5228 × 11 locales green.
-
-### Fixed
-- **Queue Start/Stop permission gates + ASAP race + /reorder validator (#1625-followup)** — Three issues caught in the post-merge audit of the unified-dispatch PR; all pre-existed on `dev` but became more impactful once every print routes through the queue. **(1) Start/Stop ownership gates.** `POST /queue/{id}/stop` required `QUEUE_UPDATE_ALL` (admin-only) and `POST /queue/{id}/start` required `QUEUE_UPDATE_OWN` with no actual ownership check. Net result: Operators saw the Stop button in the queue UI but got 403 on click; meanwhile any _OWN holder could start anyone's queue item via direct API. Both routes now use `require_ownership_permission(QUEUE_UPDATE_ALL, QUEUE_UPDATE_OWN)` with explicit ownership matching, mirroring `/cancel`. Stop is strict (mirrors cancel — _OWN cannot stop unowned items because stop is destructive and there's no claim semantic). Start is softer (preserves #1670's VP-import flow — _OWN can start NULL-owner items and claim ownership at click-time). Frontend `QueuePage.tsx` Start and Stop buttons flip from `hasPermission('printers:control')` to `canModify('queue', 'update', item.created_by_id)` so the FE matches the BE behaviour exactly. **(2) ASAP TOCTOU race.** Concurrent ASAP inserts to the same printer scope could both compute `MAX(position)` from before the other commits — in a non-empty scope, Postgres's row-level locks on the UPDATE shift serialise naturally, but the empty-scope path has no rows to lock, so both transactions inserted at `position=1` (duplicate). The fix wraps the read+update in a Postgres `pg_advisory_xact_lock(1625, scope_key)` where `scope_key = printer_id or 0`. Transaction-scoped, released automatically at commit/rollback, namespaced by 1625 so it can't collide with other advisory locks elsewhere in the codebase. Different printers don't contend. SQLite serializes writes implicitly so this is a no-op there. **(3) /reorder duplicate-position validator.** `POST /queue/reorder` set `item.position = reorder_item.position` in a loop without uniqueness validation — a buggy drag-drop client sending two items at the same position would leave the queue with ambiguous ordering (the scheduler's `ORDER BY (printer_id, position)` ties break by physical row order, making dispatch non-deterministic). New `model_validator(mode="after")` on `PrintQueueReorder` rejects the payload at the schema layer with 422 + "Duplicate positions in reorder request: [N, …]" so the FE can surface the actionable detail. Uniqueness is enforced WITHIN the payload only — cross-printer reorders that intentionally share positions across different printer queues are a non-goal of the drag-drop UI, so this is the right scope. **Tests.** 7 new integration cases in `test_ownership_permissions.py::TestQueueOwnershipPermissions`: operator can start own item, operator cannot start others' item, operator can start unowned item and claims ownership (#1670 regression guard), operator can stop own printing item, operator cannot stop others' printing item, operator cannot stop unowned printing item, admin can stop unowned printing item. 2 new integration cases in `test_print_queue_api.py::TestReorderEndpoint`: 422 on duplicate positions with "duplicate" surfaced in the detail; 200 on unique positions with positions actually updated in the DB. **Scope.** No DB migration, no new permission, no i18n string change (existing `noStopPrint` / `noStartPrint` keys cover the new ownership-mismatch case verbatim). The advisory lock is Postgres-only and held inside the existing request transaction; SQLite path is unchanged. The /reorder validator runs before the DB session opens any rows.
-
-- **AMS drying "Rotate spool" toggle no longer offered when any tray is threaded out** — The drying popover's "Rotate spool during drying" toggle was always clickable, but rotation is mechanically impossible whenever any tray in the targeted AMS has its filament threaded out into the feed tube. The whole AMS rotates as one mechanism (all 4 spools turn together), so a single loaded slot locks the entire unit. The firmware enforces this (rejects with `dry_sf_reason=[3]` "ConsumableAtAmsOutlet", surfaced as a 409 toast in `routes/printers.py:1754`), but the user got to the failure only after clicking Start. The new mid-print drying path (above) makes it worse — the temptation to click rotate during a print is now reachable. **Gate signal.** Per-tray Bambu `state`: `9` = empty, `10` = spool present but NOT loaded into tube (rotation possible), `11` = loaded into tube (rotation impossible). `PrintersPage.tsx` derives `trayLoadedInThisAms = (targetAms?.tray ?? []).some(t => t.state === 11)` using the existing `amsData` array (already cached against MQTT flicker). **Why `tray.state === 11` and not the printer-level `tray_now`.** A first cut of this gate keyed on `tray_now` (the global slot currently feeding the toolhead) — but on the H2D, after a print finishes the firmware resets `tray_now` to 255 (nothing actively feeding) while leaving the filament threaded into the feed tube. The tray's `state` stays at `11` in that idle-but-threaded condition; `tray_now` does not. Reported live by a user with all AMS units showing loaded spools and the rotate toggle still active. **Per-AMS isolation preserved.** AMS-A having a tray in state 11 does NOT disable rotation on AMS-B — both can dry, and AMS-B's mechanism is still free. **Submission clamp.** The Start handler also clamps `rotateTray: dryingRotateTray && !trayLoadedInThisAms` before mutating, so a sequence of "user enables rotate on AMS-B → user loads filament from a slot in AMS-B while popover is open → user clicks Start" can't leak `rotate_tray=true` through to the firmware. Without the clamp, the firmware-side rejection would still catch it, but the user would see a 409 toast for a mistake they couldn't have known about. **Conservative on missing state.** Trays with `state === undefined` (older firmware that doesn't populate the field) are treated as not-loaded — rotation stays available and the firmware-side `dry_sf_reason` check remains the safety net. **i18n.** 1 new key (`printers.drying.rotateUnavailableReason`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5355 leaves per locale, no English fallback. **Tests.** 9 new cases in `PrintersPageDrying.test.ts::rotate tray gate` cover: null `targetAmsId` (modal closed) → false; AMS id not in amsData → false; all trays empty (state=9) → false; all trays spool-present-not-loaded (state=10) → false (the case the user reported was firing incorrectly); any tray loaded (state=11) → true; per-AMS isolation (loaded AMS-A leaves AMS-B free); missing `tray` array → false; missing `state` field → false (conservative default-allow); submission clamp collapses to false when gate active, passes through when inactive. Vitest 41/41 green. Frontend `npm run build` clean.
-
-- **Forecasting now groups spools by colour + Forecast UI rework (#1814, by @Keybored02)** — The Forecast tab previously grouped spools by `(material, subtype, brand)` only, so a farm running Bambu PLA Basic in Jade White, Bambu Green, and Sunrise Orange saw all three pooled under one "Bambu PLA Basic" row — usage rate, reorder point, and lead-time override all averaged across colours the user actually buys and tracks separately. **Grouping key.** `skuKey(material, subtype, brand)` becomes `skuKey(material, subtype, brand, color_name)` everywhere the forecast surface fans out: row keys, settings map, alert banner labels, usage chart series, add-to-cart payloads, shopping-list lookups, CSV export, label rendering. **Pre-upgrade settings preserved via a NULL-colour fallback.** When a colour-keyed group has no own settings row, the panel falls back to the legacy `color_name IS NULL` row at `ForecastPanel.tsx:241-244` so users keep their lead-time / safety-margin customisations from before the migration. Upserts write a NEW colour-specific row — the legacy NULL row stays in place as the implicit "any-colour default" for as-yet-unconfigured colours of the same `(material, subtype, brand)`. **History-rate algorithm rewrite.** `computeHistoryRate` aggregates usage events by UTC calendar day (`r.created_at.slice(0, 10)`) before computing inter-day g/day rates. Fixes two problems: (a) concurrent multi-spool prints on the same day no longer produced near-zero inter-event gaps that inflated per-interval rates (the previous 0.5d floor only partially masked it — multiple spools printing within seconds of each other still cleared 0.5d after rounding); (b) the oldest event's weight is no longer silently dropped — it contributes to its day bucket. Same exponential 30-day half-life weighting (`λ = ln(2)/30`); rate is null when fewer than 2 distinct days of history exist (the existing delta-rate fallback covers the gap). **Reset-spool history filter.** Pre-reset events on a spool that's had "Reset usage" applied (#1390) have no anchor weight and would inflate the post-reset rate; the history pool now skips any spool with `weight_used_baseline > 0` entirely. Conservative: a group whose every spool has been reset gets coarser projection via delta-rate, but never wrong projection from a phantom pre-reset baseline. **UI rework.** New compact table layout: dedicated **Spools** column (was a subtitle line under SKU); full-row stock progress bar with gram value inline; filter chips for **Material** and **Brand** alongside the chart-days toggle (using existing `inventory.material` / `inventory.brand` keys — no new locale work); expanded row collapses the previous two-grid layout into one 4-column compact strip (effective lead time + reorder point + SKU lead-time override + safety margin). Chart tooltip rewritten — date header + one row per series with colour dot and gram value, replaces the per-series-item format that obscured cross-series comparison. Shopping-cart purchase / receive icons resized + opacity tuned for visibility (existing `text-bambu-gray` blended into the surrounding row). **Shopping list carries colour through.** New `color_name` column on `filament_shopping_list`; the modal pre-populates it from the forecast group; CSV export gains a Colour column between Subtype and Weight; labels everywhere render `[brand, material, subtype, colour].filter(Boolean).join(' ')`. CSV header strings switched from hardcoded `'Brand'` / `'Subtype'` to `t('inventory.brand')` / `t('inventory.subtype')` / `t('inventory.color')` (existing depth-1 keys present in all 11 locales). **"SKU Lead Time Override" → "Lead Time Override".** The "SKU" prefix in the expanded row was redundant given the surrounding settings panel context. English label only; other locales keep their existing translations of the prior phrasing — the i18n key is unchanged. **DB migration.** New `color_name VARCHAR(100)` nullable column on `filament_sku_settings` and `filament_shopping_list`. UNIQUE constraint on `filament_sku_settings` widened from `(material, subtype, brand)` to `(material, subtype, brand, color_name)` so the same `(material, subtype, brand)` triple can carry one settings row per colour. **SQLite path** rebuilds the table when the legacy 3-column unique index is detected via `PRAGMA index_list` introspection — idempotent across restarts; once the rebuild has run, the check exits early. **Postgres path** drops and re-adds the named constraint `uq_filament_sku` so the model declaration stays in sync. The drop/re-add is gated on a `pg_constraint` lookup — without the gate, every backend startup would issue both `ALTER TABLE`s, each acquiring an ACCESS EXCLUSIVE lock briefly, churning the constraint for no reason. SQLite + Postgres parity verified per [[feedback_sqlite_and_postgres_upfront]]. **Verification.** `ForecastPanelPermissions` 7/7 green on the merged code. Backend `ruff check backend/` clean across the four touched files (`core/database.py`, `api/routes/inventory.py`, `models/filament_sku_settings.py`, `models/shopping_list.py`). Frontend `npm run build` clean. No new permission. No change to ForecastPanel's existing read/write permission gating.
-
-- **False-positive "Print Stopped" notification on reprint after MQTT reconnect (#1807, reported by @volodymyr-doba)** — Reporter's P1S with a heavily-queued workload sporadically fired a "Print Stopped" push notification while the print was still actively running (screenshots showed 48% progress + a "50% Complete" notification 10 minutes after the spurious stop). Caught in a support bundle: `[RECONCILE] Printer 1: synthesising missed PRINT COMPLETE for archive 31 — subtask_id changed ('1844213296' → '2103771517')` at 10:26:08, immediately after the MQTT reconnect signature (`Requesting firmware version info` + `Sending K-profile priming request`) and immediately followed by `gcode_state: RUNNING` on the same wire — proof the print never actually stopped. **Root cause.** `bambu_mqtt.py:3647` mints a fresh `submission_id` (`int(time.time() * 1000) % 2_147_483_647 or 1`) per dispatch, so the queue-reprinting flow sends a NEW `subtask_id` to the printer each time. `main.py:2564` and `main.py:2773` set `archive.subtask_id` only when the stored value was empty (`not archive.subtask_id`) — so on reprint the row kept the FIRST run's id. When MQTT reconnects mid-print (network blip, printer reboot, Bambuddy restart), `reconcile_stale_active_prints` from #1542 runs against every `status="printing"` archive; `_is_active_archive_stale` at `main.py:3722` compared the stale stored id against the printer's live id, returned `(True, "subtask_id changed (…)")`, and the synthesizer fired a `status="aborted"` payload through `on_print_complete` — which is the exact code path the "Print Stopped" notification listens to. Collateral: the in-progress queue item was marked `cancelled` because the synthesized complete killed Bambuddy's tracking state. **Fix.** Two places in `main.py` — the expected-print promotion branch (line 2564) and the duplicate-printing-archive branch (line 2773) — now update `archive.subtask_id` whenever the new effective id **differs** from the stored one, not only when the stored one is empty. The comparison-based gate preserves the noop-on-stable-push behaviour the original `not archive.subtask_id` guard provided (the same id from a repeat push doesn't trigger a rewrite) while picking up reprint dispatches that mint a fresh id. The path is already inside expected-print promotion — meaning Bambuddy itself dispatched this print in-process — so the live id genuinely belongs to this archive; the write is safe. **Why the second notification ("Nozzle/Extruder Error") in the screenshot is unrelated.** That's a separate HMS event at 10:26:25, likely a stored error code the printer flushed on reconnect. The queue scheduler correctly observed `state=RUNNING, awaiting_plate_clear=True` at 10:26:08 and did NOT try to dispatch the next queue item, so the HMS "Device is busy" reason is NOT a side-effect of the bogus reconcile — both notifications fired in the same minute because they share a common trigger (the MQTT reconnect). **Tests.** 3 new cases in `test_reprint_updates_subtask_id.py` exercising the actual `on_print_start` expected-archive path: stale stored id gets rewritten on reprint (the #1807 scenario, asserts `archive.subtask_id == "2103771517"`); first-run still sets the id on an archive with `subtask_id=None`; stable repeat-push with the same id leaves the field untouched. Mocks the full session / printer / notification / WS / smart-plug / MQTT-relay surface that `on_print_start` touches so the test stays unit-fast. Full backend `pytest -n 8`: 552/552 in 17 s on the print-lifecycle suite. Ruff clean. **Scope.** Two-line guard rewrite; no DB migration, no schema change, no notification surface change, no frontend change. The reconciler itself (the consumer of `archive.subtask_id`) stays unchanged — it was doing the right thing given the data it had.
-
-- **SpoolBuddy "Assign to AMS" pushed Generic instead of the user's custom slicer preset (#1815, reported by @Bgabor997)** — On P1S, assigning a spool with a Bambu Cloud user preset (PFUS-prefix `slicer_filament`) via SpoolBuddy left Bambu Studio's Device tab showing "Generic " instead of the actual custom preset (e.g. "Jayo PETG HF (Custom)"). Manual "Configure" from Bambuddy's AMS card with the same preset worked correctly — reporter triple-checked the preset existed in their slicer and that Bambuddy's inventory showed the right name. **Root cause** in `slicer_filament_resolver.py:197-203`: the defensive filter that catches PFUS / PFCN cloud-preset IDs leaking into `tray_info_idx` (the printer's calibration table can't key on cloud-preset hashes) cleared `setting_id` alongside `tray_info_idx`. `setting_id` is what the slicer uses to find the actual preset, and PFUS / PFCN are VALID values there — they're only invalid as `tray_info_idx`. When the Bambu Cloud detail lookup didn't return a `filament_id` (cloud-unauth on the `on_ams_change` replay path, transient cloud failure, or older custom presets whose detail JSON omits `filament_id`), the resolver fell through to `normalize_slicer_filament` which round-tripped the PFUS as `tray_info_idx`, the defensive filter fired, BOTH fields were cleared, and the caller's generic-material fallback at `inventory.py:157-165` filled `tray_info_idx=GFG99` AND `setting_id=GFSG99` — Bambu Studio resolved the slot to Generic PETG. The manual Configure modal works because the frontend at `ConfigureAmsSlotModal.tsx:503-512` calls the cloud detail API itself, sets `trayInfoIdx` to the resolved `filament_id`, and **preserves** the `PFUS` as `setting_id` in the request — so the backend's MQTT push lands both correct fields. **Fix.** The defensive filter still clears `tray_info_idx` for PFUS / PFCN / material-name leaks, but now preserves `setting_id` when it's a valid slicer reference (`PFUS` / `PFCN` cloud user/shared preset, or `GFS` Bambu official preset). Material-name leaks (e.g. `setting_id="PETG"`) are still cleared — those are never valid slicer references. Post-fix MQTT push carries `tray_info_idx=GFG99` (generic — firmware-acceptable for HMS / drying / colour matching) AND `setting_id=PFUS` (slicer uses this to load the user's actual custom preset). **What stays the same.** Bambuddy's own AMS card still reads `tray_info_idx` from `raw_data` and displays the generic material on cloud-unauth paths — same fundamental limitation as today, because without cloud resolution the backend has no way to know the real `P*` filament_id. This is the secondary symptom the reporter mentioned ("Bambuddy configure modal also shows Generic as default"); fixing it requires a deeper layered fallback (look up `LocalPreset` by name, or query the printer's live `kprofiles`, or cache the resolved cloud detail) and is out of scope for this drop. The slicer-side fix is the user's explicit ask. **Tests.** 5 new cases in `test_slicer_filament_resolver.py`: PFUS cloud-unavailable preserves setting_id (reporter's scenario); PFCN cloud-unavailable preserves setting_id (#1648 partner-preset shape); PFUS cloud-resolved still works as before (regression guard); GFS cloud-unavailable resolves via `normalize_slicer_filament` (regression guard for the Bambu-official cloud-down path); literal material name ("PETG") still clears both (regression guard that PFUS preservation doesn't accidentally preserve material-name leaks). Full backend `pytest -n 30`: 6410/6410 in 73 s. Ruff clean. **Scope.** No DB / API surface / i18n / frontend changes. No change to the caller (`apply_spool_to_slot_via_mqtt`) — its `if tray_info_idx and not setting_id` guard at `inventory.py:172` already preserves whatever setting_id the resolver returns. No change to the manual Configure modal path (already carried both fields end-to-end). The `on_ams_change` replay path in `main.py` (which passes `current_user=None` and was the original motivator for the defensive filter) now also preserves setting_id — same desired outcome since the replay only fires when SpoolBuddy pre-assigned an empty slot and the spool was later inserted, and the slicer needs the setting_id to resolve the right preset.
-
-- **H2S active-tray highlight stuck on AMS slot 1 during external-spool prints (#1822, reported by @ojimpo)** — On H2S (single-nozzle, `n3f` AMS), prints feeding from the external spool showed AMS SLOT 1 highlighted in the UI for the entire job. Display-only — Spoolman usage credit was already correct via the #1276 `ams_mapping=[-1]→254` path — but the active-tray ring on the printer card pointed at the wrong spool. **Root cause** in `bambu_mqtt.py::_handle_ams_data`: X1C / P1S / A1 firmware correctly reports `tray_now=254` when the external spool is the active feed, so the single-nozzle branch's `0–3` passthrough never sees it. H2S firmware instead reports `tray_now=0` (the AMS's idle slot) throughout external-only prints — reporter's MQTT debug log captured 2883 pushes with `tray_now=0`, 143 with `255` (unloaded), zero with `254`. The single-nozzle branch then trusted the wire value and `state.tray_now` landed on slot 0. **Fix.** The single-nozzle branch now checks `_captured_ams_mapping` (the slicer-captured per-filament mapping that the request-topic intercept already tracks) before the existing P2S multi-AMS resolver runs. When every entry is `-1` (the print uses ONLY the external spool — `-1` is the canonical external sentinel), `state.tray_now` is promoted to `254` regardless of what the AMS dict says. The override is intentionally narrow: it only fires when the captured mapping is non-empty AND every entry equals -1. Mixed prints (`[5, -1]`) and AMS-only prints (`[5]`) are NOT overridden — reporter only confirmed the bug for the all-external case, and we have no evidence H2S misreports mid-print swaps; trusting the firmware on those paths preserves correctness for users with multi-filament setups. Prints started without a captured mapping (printer-screen start, or before Bambuddy connected to MQTT) fall through unchanged — the wrong value persists in that edge case, but no other regression. **No model gating.** Future single-nozzle models with the same firmware quirk inherit the fix for free, and printers that already report `254` correctly enter the override branch but the assignment is a no-op (assigning 254 when the wire said `tray_now=254` requires `parsed_tray_now <= 3` to be false in the first place — the branch never even reaches them). Dual-nozzle (H2D / H2C / X2D), the P2S multi-AMS local-slot resolver (#420), `tray_now > 3` (already a global ID), and `tray_now=255` (unloaded) are all unchanged. **Tests.** 7 new cases in `TestTrayNowH2SExternalSpoolOverride` pinning every limb of the contract: all-external `[-1]` promotes; multi-external `[-1, -1, -1]` also promotes; AMS-only `[5]` does NOT override; mixed `[5, -1]` does NOT override; `_captured_ams_mapping=None` does NOT override; empty list `[]` does NOT override (defensive — `all([])` returns True, so we explicitly guard); unload after override correctly transitions `254 → 255`. Adjacent single-nozzle X1E and P2S test classes stay green — they don't set `_captured_ams_mapping` so the new branch falls through to the unchanged path. Full backend `pytest -n 30`: 6405/6405 in 71 s. Ruff clean. **No frontend, schema, or API surface change** — the wire format and the `PrinterStatus.tray_now` field shape are unchanged; only the value computed for that field on H2S external-only prints is now correct.
-
-- **`require_previous_success` permanently blocked a printer's queue after one failure (#1818, reported by @jmassardo)** — Reporter scenario (P1S, farm-style queue): every queued job had "Only start if previous print succeeded" set; the first job failed (filament tangle / runout / clog); every subsequent job — including brand-new ones added after the printer was fixed and back online — was silently marked `skipped` with no path back. The only workaround was to delete each item and re-create it through PrintModal, impractical at farm scale. **Root cause** in `print_scheduler.py::_check_previous_success`: the lookback query returned the most recent terminal item in (`completed`, `failed`, `cancelled`, `aborted`) — `skipped` is intentionally excluded (#1667). Once the original failure landed, the lookback always walked back to that same failed item — every new skip is excluded from the lookback so it doesn't shift the window. With no code path to dismiss the originating failure, the gate stayed closed forever. "Clear Plate" only resolved the orthogonal plate-clear gate (`_is_printer_idle`); nothing in Bambuddy acknowledged a resolved failure. **Fix.** New `PrintQueueItem.gate_acknowledged: bool` column (default False). Postgres/SQLite-safe ALTER following the existing #1794 / stock-alert migration shape (`DEFAULT 0` on SQLite, `DEFAULT false` on Postgres). `_check_previous_success` adds `AND gate_acknowledged == False` to its lookback so acknowledged failures are walked past — back to whatever real predecessor came before, or to the no-predecessor-passes case. **New per-printer endpoint.** `POST /api/v1/queue/printer/{printer_id}/resume` (gated on `QUEUE_UPDATE_ALL`) does both halves of the resume in one atomic transaction: (1) `gate_acknowledged=True` on every failed/aborted item for that printer that's still gating; (2) flips items where `status='skipped' AND error_message='Previous print failed or was aborted'` back to `pending`, clears `error_message` + `completed_at`. Returns `{acknowledged, restored}` counts so the UI can render a precise toast. Per-printer scoped — a resume on printer A never touches printer B. Per-item acknowledgement is independent — a fresh failure AFTER a resume re-gates downstream items, so users don't silently steamroll past a new real problem. The endpoint is idempotent (second call after the first sees acknowledged=0, restored=0). Also intentionally narrow: skipped items whose `error_message` is something OTHER than the exact gate string (e.g. future skip reasons, manual UI skips) are left untouched — those encode different user intent. **Frontend.** Per-printer alert banner at the top of the Queue tab (above the layout / filter row) shown when a printer has at least one skipped item with the gate `error_message`. Banner is permission-gated on `queue:update_all` so viewers don't see a button they can't use. Each blocked printer gets its own row: an `AlertCircle` icon, a one-line "{printer} is blocked by a previous-print failure — N job(s) skipped" headline, a "Fix the printer issue, then resume to restore the skipped jobs and clear the gate." hint, and a "Resume after failure" button on the right that opens a warning-variant `ConfirmModal` with the printer name + count. Confirm fires the new mutation, invalidates `['queue']`, and shows a toast — "Resumed queue — N job(s) restored to pending". Banner disappears the moment the action lands. Visible on the active Queue tab regardless of layout (`position` or `printer`); History/Timeline tabs unchanged. **Backend tests** (12 new): 4 new `_check_previous_success` cases in `test_check_previous_success.py` covering acknowledged failure ignored, acknowledged aborted ignored, fresh failure after ack STILL gates (independence guarantee), acknowledged failure walks back to the prior completed predecessor. 7 new `TestResumeQueueAfterFailure` cases in `test_print_queue_api.py` covering unknown printer 404, clean-queue no-op, reporter's failed+N-skipped scenario, scoped to the requested printer only, aborted-status acknowledgement, narrow `error_message` filter (don't touch other skip reasons), second call is a no-op. Full backend `pytest -n 30`: 6398/6398 green, 75 s. `ruff check backend/` clean. **Frontend** — 1 new API client method (`resumeQueueAfterFailure`), parity check 5352 × 11 locales green (no English fallback). `npm run build` clean, `npm run lint` clean. **i18n.** 7 new keys (`queue.toast.resumedAfterFailure`, `queue.toast.resumeAfterFailureFailed`, `queue.resumeAfterFailure.banner` / `bannerHint` / `button` / `confirmTitle` / `confirmMessage`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). **Scope.** No change to `_is_printer_idle` (Clear Plate stays orthogonal). No change to `#1667` `skipped`-excluded-from-lookback semantics — those are still load-bearing for the cancellation-cascade fix. No change to filament-deficit-skip path (different error_message → untouched). One legacy column carries a row state where `gate_acknowledged=True`; the column never gets written by anything except the resume endpoint, so it's effectively an opt-in row marker. No new permission — `QUEUE_UPDATE_ALL` already exists and is the appropriate scope for a printer-level operation that may affect items the user doesn't own.
-
-- **Archives "Step 4" docs link 404'd (#1812, reported by @Spanholz)** — The Archives page's "Some prints couldn't be archived with thumbnails" amber banner linked to `https://bambuddy.cool/wiki/getting-started/#step-4-enable-store-sent-files-on-external-storage` — the wiki lives at the `wiki.bambuddy.cool` subdomain, not under `bambuddy.cool/wiki/`, so the link 404'd. Anchor was already correct (MkDocs slugifies the existing "Step 4: Enable Store sent files on external storage" heading to that exact id). Single-line fix in `frontend/src/pages/ArchivesPage.tsx:3271`; no i18n / no tests touched.
-
-- **Connection diagnostic reported false camera-port warning on A1 / A1 Mini / P1 (#1798, reported and fixed by @lesbass / Stefano Maffeis in #1799)** — The general Connection Diagnostic (`printer_diagnostic.py:124`) probed RTSPS port 322 unconditionally, even for printers that don't speak RTSP. A1, A1 Mini, and P1-family cameras use the chamber-image protocol on port 6000 — Bambuddy's live camera client already routes them there via `camera.py::get_camera_port(model)`. Result: a saved A1 Mini with a working live webcam still saw "Camera port (RTSPS 322) — Port 322 is unreachable" in the diagnostic, and overall status flipped to `warnings` for a healthy printer. Reporter confirmed: TCP probe from the container showed 6000 open, 322 refused; camera-specific diagnostic returned `protocol=chamber_image, port=6000, overall_status=ok`. **Fix.** The diagnostic now resolves the camera port via the same `get_camera_port()` the camera client uses (single source of truth — adding a new model in `camera.py` propagates here for free). Saved A1 / A1 Mini / P1 → probe 6000, render "Camera port (Chamber Image 6000)". Saved X1 / X2 / H2 / P2 → unchanged, still probe 322 / RTSPS. Pre-save Add-Printer diagnostic where the model isn't known yet falls back to 322 / RTSPS — same conservative default as before, so the Add-Printer flow doesn't regress for users on RTSP models. The diagnostic check id stays `port_rtsps` for backward compatibility with snapshot JSON consumers; the actual port / protocol now travel in `params` and the localized title and warn text interpolate `{{protocol}}` / `{{port}}`. **Frontend.** `ConnectionDiagnostic.tsx` interpolates the title (previously static) and merges defensive defaults (`{ protocol: 'RTSPS', port: 322, ...check.params }`) for `port_rtsps` so an older backend or cached response still renders sensibly. **i18n.** Title and warn strings updated in all 11 locales (en / de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) to use `{{protocol}}` / `{{port}}` interpolation. `npm run check:i18n` clean. **Tests.** 2 new backend cases (A1 Mini → 6000 / Chamber Image with the 322 fallback closed asserting `port_rtsps=pass`; X1C → still probes 322 / RTSPS asserting the existing behavior is unchanged) and 1 new frontend case asserting the rendered "Camera port (Chamber Image 6000)" + "Port 6000 is unreachable" text. `_port_probe` defaults extended to include port 6000. Existing model-less tests unchanged — they go through the `printer.model is None` fallback path and still probe 322.
-
-- **Print-complete notification dropped the finish photo when the FINISH-state fallback fired (#1790, reported by @needo37)** — On the FINISH-state fallback path (`bambu_mqtt.py:3258-3297`, used when stage-22 doesn't fire — cancel, external-spool-only, HMS halt, firmware variants that skip the unload phase), `on_finish_photo_moment` and `on_print_complete` were dispatched as two **independent** asyncio tasks back-to-back from the same MQTT handler. The producer (`on_finish_photo_moment`) ran the RTSP grab (15s timeout) and stored the JPEG into `_stage22_finish_frames[printer_id]` only after the grab returned; the consumer (`_background_finish_photo`, spawned by `on_print_complete`) read the cache with a single `pop()` at `main.py:4681` — no wait, no retry. On the stage-22 happy path the producer fires seconds before FINISH-state arrives so the race is invisible; on the FINISH-state fallback the gap collapses to ~0 and the consumer always wins the empty pop. After the empty pop, the fallback chain called `capture_finish_photo()` at `main.py:4739` — but the producer's RTSP grab was still in flight against the same printer, and Bambu printers allow exactly one RTSP client at a time. The consumer's grab timed out at the camera service's 30s ceiling. Reporter's log shows it exactly: `[FINISH-PHOTO-MOMENT] captured RTSP frame (394037 bytes)` at 05:31:20, then `[PHOTO-NOTIFY] Photo task returned: None` at 05:31:49 — 30s after `[PHOTO-BG] Starting`. A 394 KB frame was captured, the notification went text-only. **Why this only surfaced after #1721:** before #1721, Bambuddy force-enabled timelapse at dispatch so a video always existed and the finish photo was extracted from its last frame regardless of timing. #1721 removed the force-on (it was causing per-layer nozzle parking on Smooth-mode slicer profiles) and made the racy stage-22 cache the only good framing source. For timelapse-off prints completing via the FINISH-state fallback, there was no resilient source left. **Fix.** New per-printer `_stage22_finish_in_flight: dict[int, asyncio.Event]` synchronizes producer→consumer. The producer registers an `asyncio.Event` BEFORE its first `await` (so the consumer always sees it the moment it polls — registration is purely synchronous before any await yields control), sets the event in a `finally` block on EVERY exit path (success, no-frame, setting-disabled early return, exception), and the consumer awaits the event with `asyncio.wait_for(event.wait(), timeout=20.0)` before reading the cache. The 20s ceiling is sized against the producer's 15s RTSP timeout — bounded headroom, can't hang notifications. The consumer pops the dict entry when it starts waiting so cleanup is a single side; the producer's `set()` works on a local ref. Side-effect win: because the consumer is blocked behind the producer's completion, the consumer's own RTSP fallback can no longer collide with the producer's in-flight grab — Failure 2 (concurrent RTSP timeout) is closed alongside Failure 1 (cache race) by the same change. **What this does NOT change.** The timelapse path (`timelapse_was_active=True`) returns before registering the event — the consumer takes the `_capture_finish_photo_from_timelapse` branch and never waits; no regression. Aborted / failed prints don't dispatch `on_finish_photo_moment` at all (status="completed" gate at `bambu_mqtt.py:3258`) — no event registered, consumer behaves as today. External-camera printers (`external_camera_enabled`) and printers with a live stream open in the UI (buffered RTSP frame) are unaffected — the producer still uses those non-contended sources first. No change to `camera.py` lock semantics. **Tests.** 7 new cases in `test_finish_photo_moment_sync.py` pin every limb of the contract: event is registered before the first await (uses a slow-capture stub to observe the dict mid-run), event is set after successful capture, event is set when the producer captured no frame, event is set even when the capture function raises (the `finally` is load-bearing), event is NOT registered on the `timelapse_was_active=True` early-return, event IS set when the `capture_finish_photo` setting is disabled (the late early return — important so the consumer doesn't hang on a no-op producer), and an end-to-end producer/consumer pair finishes promptly with the cached frame visible to the consumer. Adjacent tests (`test_finish_photo_from_timelapse.py`, `test_reprint_clears_stale_timelapse.py`) still green. Ruff clean.
-
-- **Archives drag-and-drop overlay stuck after cancel (#1510, reported by @maikolscripts)** — Cancelling a drag on the Archives page — by dragging back out of the browser window, releasing outside the page, or pressing Escape mid-drag — left the full-screen "Drop .3mf files here" overlay visible until the user refreshed. **Cause.** The old inline `handleDragLeave` only hid the overlay when `e.currentTarget === e.target` (i.e. the dragLeave event fired on the wrapper itself, not a child). That condition was structurally safe for crossing internal element boundaries but rarely held for the three cancel paths above — drag-out-of-window fires dragLeave with `target` at the nearest child to the cursor; Escape and drag-abort fire no leave event at all on the wrapper. **Fix.** Moved the page-wide drop handling into the new `usePageFileDrop` hook (also consumed by File Manager — see the linked Added entry). The hook checks `relatedTarget` containment instead of `currentTarget === target`, and adds document-level `drop` / `dragend` / `keydown(Escape)` listeners that only register while `isDraggingOver === true` so the cancel paths all reset uniformly. Three of the 13 new hook test cases pin the cancel paths explicitly so a future regression on any one of them fails its own case. Also moved the previously-hardcoded English "Drop .3mf files here" string in `ArchivesPage.tsx:3202` to the existing `archives.page.dropFilesHere` i18n key (which already had translations in all 11 locales) so the overlay localises correctly — same change of behaviour as `archives.releaseToUpload` already had.
-
-- **File Manager list-view column headers misaligned with their body cells** — Both the header row and each list row used the same `grid-cols-[auto_1fr_120px_100px_100px_100px_min-content]` template — looked correct at the CSS level — but the two ``s were **sibling grids**, not a shared grid, so each computed `min-content` for the trailing actions column independently. The header's trailing column is an empty `
` → `min-content` resolved to 0; body rows had 4–7 action icons → `min-content` resolved to ~220px. With different trailing widths, the `1fr` Name column got a different amount of room in each grid, which pushed every fixed column to its right (`Uploaded By`, `Type`, `Size`, `Prints`) further right in the header than in the body. Visually the body cells looked **shifted left** of their column headers. **Fix.** Replaced the trailing `min-content` with a fixed `220px` in both the auth-enabled and auth-disabled grid templates (matching the comment that already documented the expected width of the 7-icon strip on sliced 3MFs). Updated the explanatory comment with the sibling-grid pitfall so the next person doesn't re-introduce it. No tests changed; the misalignment was purely visual (no DOM ordering / interaction changed), and the existing 51 FileManagerPage tests stay green.
-
-- **Mid-print AMS Backup spool-switch credited the entire print to the second spool instead of splitting the weight (#1771, reported by @biduleman)** — Reporter (P1S) forcefully started a print needing ~260 g with only 180 g remaining on the first spool; the printer correctly consumed the first spool, AMS Backup auto-switched to a same-material second spool, and finished the print. Bambuddy then attributed ALL 260 g to the second spool; the first spool was left untouched in the inventory. Reads as a usage-attribution bug; root cause is two stacking firmware-quirk bugs that produce exactly the all-to-second-spool symptom for prints without per-layer 3MF gcode. **Bug A — firmware reset of `total_layer_num`.** `bambu_mqtt.py:2135` wrote `state.total_layers = int(data["total_layer_num"])` unconditionally on every push containing the field. P1S firmware (observed; matches the pattern other models reset `layer_num` / `progress` via at print end) pushes a `total_layer_num: 0` frame at print completion. The unconditional write clobbered the slicer's actual total — by the time the usage-tracker ran a frame or two later, `state.total_layers` was 0. The existing `_last_valid_layer_num` guard at line 2127 covered the same race for `layer_num` but the equivalent guard for `total_layers` was never added. **Bug B — `usage_tracker.py:1129-1137` dumped-all-to-last fallback.** The mid-print tray-switch split path (which handles AMS Backup → second spool exactly like this scenario) has three attribution branches per segment: per-layer 3MF gcode (precise), linear by layer ratio (`total_layer_num`-based), and "remainder" (the last segment always gets `total_weight - sum_previous`). When per-layer 3MF data is unavailable (force-started prints often lack it) AND `total_layers == 0` (Bug A had just fired), the linear branch silently produced `segment_grams = 0.0` for every non-last segment — so the entire print weight collapsed onto the last segment's remainder calculation. Path 2 (AMS remain% delta) couldn't recover because (1) the just-emptied spool typically reports `remain=-1` after the empty event so the percent-delta calculation rejected it, and (2) the second spool's tray key had already been added to `handled_trays` by Bug B's misallocation, suppressing the Path 2 lookup. End result: 260 g credited to spool 2, spool 1 left at 180 g unchanged — exactly the screenshot the reporter posted. **Fix A — `bambu_mqtt.py`.** Mirror the existing `_last_valid_layer_num` shape: only overwrite `state.total_layers` when the incoming `total_layer_num` is positive, so a firmware-reset frame can't clobber the cached value. The explicit reset to 0 on new print start now lives in the `_handle_print_start` block at line ~3132 (right next to the existing `state.layer_num = 0`) so the previous print's total still can't bleed into the next one before its first real push arrives. **Fix B — `usage_tracker.py`.** Cascade the linear-fallback denominator: try `state.total_layers` first (the canonical source), then `last_layer_num` (the print's last-valid layer captured at completion time, already threaded into `_track_from_3mf` as a parameter for the `last_progress` partial-print case), then equal-split across segments as a last-resort fence — still wrong, but bounded. The original behaviour was strictly worse than equal-split: it always dumped 100% of the print's weight onto the last segment regardless of where the switch actually happened. **Tests.** 5 new backend cases. `test_usage_tracker.py::TestTrayChangeSplit::test_tray_switch_uses_last_layer_num_when_total_layers_reset` — the reporter's exact 260 g / 180 g split scenario with `state.total_layers=0` and `last_layer_num=260`, asserts 180.0 / 80.0 g attribution. `test_usage_tracker.py::TestTrayChangeSplit::test_tray_switch_equal_split_when_no_layer_info_at_all` — both denominators unavailable, asserts equal-split (30.0 / 30.0 g for a 60 g print) instead of the dump-to-last behaviour. `test_bambu_mqtt.py::TestTotalLayersPreservation` (× 3) — non-zero push sets the field; zero push preserves the cached value; new-print-start path explicitly resets to 0. Existing 7 `TestTrayChangeSplit` cases (including the precise-per-layer-gcode happy path and the `total_layers=100` linear fallback regression at line 1045) still green — they use a positive `total_layers` so the new cascade is dormant for them. **Scope check — what this does NOT change.** The precise per-layer 3MF branch (`extract_layer_filament_usage_from_3mf` returns data) is preferred over the linear fallback whenever per-layer data is available, so users who slice through PrintModal with full 3MF analysis stay on the precise path. Single-tray prints (no AMS Backup switch) never enter the split path at all (`len(tray_changes) > 1` gate). Path 2 (AMS remain% delta) is unaffected — it only fires for trays Path 1 didn't already attribute, and the fixed Path 1 covers the correct trays now. **One intentional semantic shift worth flagging:** `state.total_layers` now persists across the firmware-end-of-print reset frame and between prints, instead of briefly dropping to 0 at completion and staying there until the next print's first push. The explicit reset in `_handle_print_start` (line ~3135, sibling to the existing `state.layer_num = 0` reset) re-zeroes it cleanly on every new print, so the previous print's total still can't bleed into the next. Audited every consumer of `state.total_layers` across the backend (`main.py`'s first-layer notification, `metrics.py`'s Prometheus gauge, `spoolman_tracking.py`'s progress estimator, `printers.py`'s REST response, `mqtt_relay.py`'s relay payload, `printer_manager.py`'s WS payload, the finish-photo-moment trigger at `bambu_mqtt.py:2206`): none distinguishes "no active print" by `total_layers == 0` — they all check `state.state` for that. So the persistence change is invisible to every existing surface, and downstream consumers that DO read the value get a more reliable number at end-of-print and across a power-cycle. Full backend `pytest -n 30` 6222/6222 in 94 s; ruff clean.
-
-- **"Prefer lowest remaining filament" did not actually pick the lowest spool, and could pick a near-empty spool on printers with AMS Filament Backup disabled (#1766, reported by @biduleman)** — Reporter (P1S) had `prefer_lowest_filament=true` with two identical-brand identical-color spools in the AMS, one with less remaining filament than the other; Bambuddy still picked the first slot every time. Root-cause investigation found TWO separate bugs feeding the one report. **(1) The frontend sort never saw inventory grams.** The backend has a two-tier sort (`_prefer_lowest_sort_key` at `print_scheduler.py:1161`) that puts inventory-bound spools in tier 0 (sorted by `label_weight - weight_used` for internal mode or Spoolman's `remaining_weight` for Spoolman mode) and MQTT-only spools in tier 1 (sorted by the printer's `remain%` field) — but it only runs on queue items dispatched without a pre-set `ams_mapping`. The PrintModal "Print Now" / "Add to Queue" flow pre-computes the mapping client-side and submits it, so the backend uses it as-is and the two-tier sort never fires for this path. The frontend's pre-compute (`useFilamentMapping.ts::computeAmsMapping` + `useFilamentMapping`, `useMultiPrinterFilamentMapping.ts::computeMappingWithOverrides` + `computeMatchDetails`, `amsHelpers.ts::autoMatchFilament`) only sorted by `remain%` and had no notion of inventory grams. For two RFID Bambu spools both reporting `remain=100` (freshly inserted, no recent prints) the sort tied at value 100, the slot-position tie-break favoured the lower slot, and the first slot always won — exactly what the reporter saw. **(2) The sort had no notion of whether the printer could actually USE a near-empty spool.** Without AMS Filament Backup enabled on the printer, the firmware will not switch to a second spool when the picked one runs out — so sorting toward the lowest left prints at risk of running dry mid-job. The user-side preference was completely ignorant of the printer-side capability that makes it safe. **Fix — dispatch-time backend gate.** `_compute_ams_mapping_for_printer` at `print_scheduler.py:867` coerces `prefer_lowest=False` when `status.ams_filament_backup is False`, logs `[prefer-lowest] skipped (AMS Backup OFF on printer %s)` so the decision is visible in support bundles without enabling DEBUG. Tri-state default: `None` (unknown / A1 family) applies the sort, preserving today's behaviour. **Fix — banding-equivalent two-tier frontend sort.** New exported `preferLowestSortKey(f, inventoryByTrayId)` in `amsHelpers.ts` mirrors the backend banding exactly — inventory-bound spools sort to tier 0, MQTT-only to tier 1, with backend-matching slot tie-break (`amsId * 4 + trayId` for regular AMS, `1000 + (amsId - 128) * 4 + trayId` for AMS-HT, `10_000` for external / VT so external always sorts LAST regardless of negative raw `ams_id`). All seven frontend sort sites switched to it: `autoMatchFilament` + 2 sites in `useFilamentMapping.ts::computeAmsMapping` (top-level + nested for non-unique tray_info_idx) + 2 sites in `useFilamentMapping.ts::useFilamentMapping` (the hook variant) + 2 sites in `useMultiPrinterFilamentMapping.ts` (`computeMappingWithOverrides`, `computeMatchDetails`) + `autoConfigurePrinter`. Hook signatures grew an optional `inventoryByTrayId?: Map
` param — undefined preserves pre-#1766 behaviour for any caller that hasn't wired it in. The banding tie-break alignment is load-bearing on its own: an earlier draft used `amsId * 4 + trayId` for all slots, which gives `ams_id = -1` (external) a NEGATIVE priority that would have beaten AMS slot 0 — caught during a second-round code audit and fixed before commit. **Fix — frontend backup gate.** New exported `effectivePreferLowest(setting, amsFilamentBackup)` mirrors the backend gate rule (`!setting → false; backup === false → false; otherwise true`). PrintModal computes it for the single-printer flow at `index.tsx:380`; `useMultiPrinterFilamentMapping` computes it per-printer inside the `printerResults.map` (different printers in the same dispatch can have different backup states, so a global flag would be wrong); PrinterSelector's `InlineMappingEditor` and `FilamentMapping.tsx`'s standalone editor (the per-AMS slot dropdown) both wired through. The standalone editor previously had NO `preferLowest` awareness at all — its auto-suggestion could disagree with what would actually be dispatched. Closed in this change. **Inventory map — single source of truth.** New `GET /printers/{id}/inventory-remain` endpoint (see Added entry above) exposes the same `_build_inventory_remain_overrides` result the dispatcher uses, so PrintModal and `FilamentMapping.tsx` get the same `Map` the backend would compute. Internal AND Spoolman modes both work uniformly via the existing scheduler helper's branch — external / VT slots excluded, negative grams clamped. Frontend fetches per selected printer via `useQueries` keyed on `'printer-inventory-remain'`, 30 s staleTime, no fetch for unselected printers. An earlier attempt derived the map client-side from `/inventory/assignments` directly — that endpoint only reads the internal-mode `SpoolAssignment` table, so Spoolman users would have silently fallen back to remain%-only sorting. The dedicated endpoint closes that gap. **Settings → Filaments tooltip.** Explanatory note added under the "Prefer lowest remaining filament" toggle description: "Only takes effect when AMS Filament Backup is enabled on the printer — otherwise the printer cannot switch to a second spool when the picked one runs out." Save behaviour unchanged (existing debounced-save fires the "Settings saved" toast). **i18n.** New key `settings.preferLowestFilamentBackupNote` translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); parity check 5198 leaves per locale, no English fallback. **Tests.** 7 new backend cases — `test_scheduler_backup_gate.py` (tri-state gate × 4: backup OFF coerces, backup ON applies, None preserves today's behaviour, user setting OFF short-circuits regardless of backup) and `test_inventory_remain_endpoint.py` (× 3: no status returns empty map, normal serialisation with string keys, no bindings returns empty). 8 new frontend cases — `useFilamentMapping.test.ts` (`computeAmsMapping` inventory × 3 + `effectivePreferLowest` gate × 5 + slot-priority banding regression × 1) and `PrinterSelector.test.ts` (`autoMatchFilament` inventory × 3). Existing 56 + 27 cases still green — backwards-compat preserved by optional new params. Full backend `pytest -n 30` 6217/6217 in 74s; full frontend `vitest` 2170/2170 in 29s; ruff clean; `npm run build` clean; eslint clean; i18n parity green.
-
-- **H2C nozzle pick from Bambu Studio not preserved on the dual-nozzle rack variant (O1C2) — plus a much wider silent-fallback bug across every Bambu Studio "Send" upload (#1780, reported by @mkoreen)** — The reporter's H2C consistently loaded R2 for HF prints and R4 for standard prints regardless of the nozzle they picked in Bambu Studio. **First-attempt fix (commit d196cfc5) was wrong about the root cause** and didn't take. The real root cause, traced through @mkoreen's `BAMBUDDY_VP_DUMP_WIRE=1` capture + the 0.2.5b1 support bundle on 2026-06-21, is a filename-key mismatch in the VP intake cache that affected EVERY Bambu Studio "Send" upload across EVERY model, not just H2C. **What was actually broken.** `mqtt_server.py:1296` was passing the slicer's bare `subtask_name` (e.g. `Filament_Track_Switch_Holder`, no extension) into the `on_print_command(filename, data)` callback. `on_print_command` then stashed the slicer's print options under that bare name. But `_add_to_print_queue` looked up the cache under `file_path.name` — the FTP filename WITH extension (e.g. `Filament_Track_Switch_Holder.gcode.3mf`). The two strings never matched. The initial `pop` returned None, the 2-second event wait fired against a key that the stash-side never signaled, the wait timed out, and every captured slicer field silently fell back to settings defaults. The user-visible blast radius: not just H2C's `nozzle_mapping` (which was added speculatively in d196cfc5 — see below), but ALSO `bed_leveling` / `flow_cali` / `vibration_cali` / `layer_inspect` / `timelapse` from the original #1403 slicer-opts capture have been silently ignored since BambuStudio started putting the bare model name in `subtask_name` and the extended filename in a separate `file` field. The mismatch survived unit tests because the existing fixtures called `on_print_command(file_path.name, {...})` directly with the FTP filename — bypassing the broken `mqtt_server.py` caller. **Fix.** `on_print_command` derives `stash_key = data.get("file") or filename` and uses THAT for both `_slicer_print_options[...]` and the event-signal lookup. `filename` (subtask_name) still flows to `_schedule_finish_release` unchanged — push_status echoes it back to the slicer as `gcode_file` / `subtask_name` and the slicer matches against its own local subtask_name there, so changing what that path receives would have introduced its own regression (caught and reverted mid-audit). The new derivation falls back to `filename` when `data["file"]` is absent (legacy slicers / non-3MF uploads), so the change is strictly an improvement or a no-op — never a regression. **H2C nozzle_mapping rides through correctly now.** The `nozzle_mapping` array BambuStudio writes into `project_file.print` at the top level (verified via wire capture: 32-entry `list[int]` matching the printer's `get_auto_nozzle_mapping` reply) now reaches the queue item and is replayed on the dispatched `project_file` command via `bambu_mqtt.py::start_print(nozzle_mapping=…)`, gated by the `is_dual_nozzle` flag at runtime. Nullable TEXT column `nozzle_mapping` on `print_queue` — non-branched ALTER matching the `ams_mapping` / `filament_overrides` precedent. The dispatcher parses the JSON-string column back to a `list[int]` on the wire so the shape matches BS exactly. Fail-open on malformed JSON: unparseable column logs a WARNING and omits the field — firmware then runs its auto-pick, never worse than pre-fix. **`nozzles_info` is dead code, dropped.** The original #1780 fix also captured a `nozzles_info` field that was a best-guess based on the OrcaSlicer source. The wire capture confirmed BambuStudio never actually sends it — the field doesn't exist on the project_file body for H2C. Captured-but-unused code removed from `manager.py` intake, `bambu_mqtt.py` dispatch + signature, `printer_manager.py` kwarg, `print_scheduler.py` call, `PrintQueueItemUpdate` + `PrintQueueItemResponse` schemas, and the `print_queue.py` route's parse / serialise paths. The DB column is kept nullable so old rows still load — nothing reads or writes to it anymore. **Diagnostic log added.** `_add_to_print_queue` now logs at DEBUG when slicer_opts is None at the end of intake, including the looked-up key and the actual cache keys present. A future stash/lookup mismatch will surface in 30 seconds of log reading instead of needing a wire capture to diagnose. **Behaviour change worth flagging.** Users with Bambu Studio-set values for bed-leveling / flow-cali / vibration-cali / layer-inspect / timelapse that differ from the Bambuddy default-workflow settings will see those slicer choices honored now instead of silently overridden. This restores the original #1403 intent — slicer values take precedence, settings defaults are the fallback. **Tests.** 3 new regression cases in `test_virtual_printer.py::TestVPProjectFileStashKey` pin the contract: the FTP filename (from `data["file"]`) MUST be the stash key, the bare subtask_name fallback when `file` is absent, and the wait-event signal must fire under the FTP-filename key even when the callback was called with the bare subtask_name. All 3 would fail under the pre-fix code. Existing nozzle / dispatch tests (`TestStartPrintNozzleMappingDispatch` × 5, `test_virtual_printer.py::TestVirtualPrinterInstance` × 3) updated to drop the now-removed `nozzles_info` assertions. `test_printer_manager.py::test_start_print_calls_client` updated for the dropped kwarg. Full backend `pytest -n 30` 6274/6274 in 92.65s; ruff clean; `npm run build` clean; vitest 2156/2156 + QueuePage 26/26 + FileManagerPage 51/51 green; i18n parity green. **Scope.** The stash-key fix affects every queue-mode Virtual Printer regardless of model. The H2C-specific nozzle_mapping pass-through is the user-visible surface that motivated finding the bug. H2D / X2D dual-extruder routing was never affected — those use `ams_mapping2` (ams_id 254/255), already forwarded correctly. No DB migration changes (the `nozzles_info` column stays on disk, just unused), no permission change, no i18n keys, no frontend changes. **Round 3 (race-window close).** @mkoreen's 2026-06-23 support bundle showed the round-2 stash-key fix worked but the dispatched `project_file` still went out without `nozzle_mapping`. Log timestamps pinned it precisely: FTP upload complete at `00:42:02.509`, `_add_to_print_queue`'s 2.000 s wait_for hit "No slicer options cached" at `00:42:04.509`, BS's MQTT `project_file` arrived at `00:42:04.594` — exactly 85 ms past the deadline. The queue item was already committed with settings defaults; the late stash sat in the cache until eviction. Bambu Studio on wireless / loaded-Pi setups can land its MQTT command well past the 2 s margin — the previous ceiling was tuned for a happy-path latency, not the observed worst case. **Fix.** Two pieces, both in `services/virtual_printer/manager.py`. (1) The wait timeout is now `_SLICER_OPTIONS_WAIT_TIMEOUT = 5.0`, extracted to a module-level constant so future tuning lives in one place. The +3 s headroom only costs anything when the slicer never sends MQTT at all (legacy / non-BambuStudio) — those uploads now wait a one-time +3 s before the queue item appears, which is acceptable for a queue-add. (2) New late-MQTT fallback: `_add_to_print_queue` now records the just-committed queue item IDs (keyed by FTP filename) into a 30 s in-memory dict; `on_print_command` checks that dict when no event consumer is waiting (meaning the wait already timed out) and retroactively UPDATEs the row's `nozzle_mapping` + `bed_levelling` / `flow_cali` / `vibration_cali` / `layer_inspect` / `timelapse` / `use_ams` fields. Gated on `status == 'pending'` — once the scheduler has picked the row up the UPDATE is a no-op to avoid racing the dispatcher. Multi-plate Send All is covered (the dict stores every committed plate's id; the UPDATE WHERE id IN (...) stamps all of them in one call). 30 s TTL evicts stale entries opportunistically on every queue-add so the dict can't grow unbounded across a long-running VP's uptime. (3) Post-commit last-chance pop. A code audit caught a narrow race in (2): MQTT could arrive during ANY await between the initial `_slicer_print_options.pop` and the recent-queue populate — `wait_for` itself, `archive_print`, `db.flush`, `db.commit` — and `on_print_command` would stash data with no event consumer AND no `_recent_queue_items` entry yet, leaving the late stash to die in the FIFO eviction. After populating `_recent_queue_items`, `_add_to_print_queue` now pops `_slicer_print_options[file_path.name]` one last time and routes any hit through `_restamp_recent_queue_item` inline before returning. Cost is a single dict pop in the happy path; the race fires the same UPDATE path as the on_print_command-triggered restamp. **Tests.** 3 new `TestVirtualPrinterInstance` cases: `test_on_print_command_late_mqtt_retroactively_stamps_queue_item` drives `_add_to_print_queue` with `_SLICER_OPTIONS_WAIT_TIMEOUT` patched to 0.05 s so the wait times out cleanly, then fires `on_print_command` and asserts the UPDATE's bound parameters carry the slicer's `nozzle_mapping` + workflow flags + correct column rename for `bed_leveling → bed_levelling`; `test_add_to_print_queue_catches_mqtt_stashed_post_wait_timeout` hijacks `db.commit`'s first call to stash slicer options mid-flight (simulating MQTT arrival during commit yield), then asserts the post-commit pop runs `_restamp` inline so the UPDATE still ships the slicer values; `test_on_print_command_late_mqtt_skips_already_dispatched_item` pre-seeds the recent-queue dict and stubs the eligibility SELECT to return zero rows, asserting the UPDATE is never issued and `commit()` is not awaited (the row is past the safe window). 139/139 `test_virtual_printer.py` green; 4208/4208 across the full backend unit suite. Ruff clean. No DB migration, no schema change, no frontend change.
-
-- **Docker installer fails on the default `/opt/bambuddy` path with "Permission denied" (#1774, reported by @jmoore-skild)** — `install/docker-install.sh::create_install_dir` (line 252) ran `mkdir -p "$INSTALL_PATH"` without sudo while `DEFAULT_INSTALL_PATH="/opt/bambuddy"` (line 32) — root-owned on every Linux distro. `set -e` at line 20 then aborted the whole run before docker compose could ever pull the image. Anyone following the documented `curl … | bash` flow as a normal user hit this immediately. The native installer at `install/install.sh:361` already handles the same situation correctly with `sudo mkdir -p` + `sudo chown`; the Docker variant just never got the same treatment. **Why the fix isn't a default-path change:** the contributor's first instinct was to drop the default to `~/bambuddy` since the Docker installer only writes `docker-compose.yml` + `.env` on the host (real app data lives in named volumes), but `install/update.sh:4` and `install/update_macos.sh:4` both default `INSTALL_DIR` to `/opt/bambuddy`, and `install/README.md:274` documents `INSTALL_DIR=/opt/bambuddy sudo ./update.sh` for the update flow — changing the install default without coordinating the update path would silently break self-service updates for anyone following the docs verbatim. The actual gap is the missing privilege escalation in `create_install_dir`, not the default path. **Fix:** `create_install_dir` now tries `mkdir -p "$INSTALL_PATH" 2>/dev/null` first — the cheap no-sudo path covers `--path ~/bambuddy`, `--path /srv/bambuddy`, and any other writable target — and only falls back to `sudo mkdir -p "$INSTALL_PATH"` + `sudo chown -R "$USER:$USER" "$INSTALL_PATH"` when the unprivileged attempt fails. The chown is load-bearing: without it, the script would later try to write `docker-compose.yml` and `.env` into a root-owned dir as the unprivileged invoking user, kicking off a cascade of EACCES failures further down. Idempotent on re-run (the second `mkdir -p` succeeds against the now-owned dir, no second sudo prompt). `set -e` survives the redirected stderr because the `if !` construct is the documented escape from bash's exit-on-error semantics for an expected-failure check. **Smoke-tested all three branches:** writable target → no sudo prompt fires; idempotent re-run → no second sudo prompt; the failing-mkdir-then-fallback path → `set -e` survives intact. **What this does NOT change:** the default install path stays `/opt/bambuddy` for parity with `install.sh` / `update.sh` / the documented update flow; the Windows mirror at `install/docker-install.ps1` already uses `$env:USERPROFILE\bambuddy` (per-user convention on Windows) and is untouched. No docs change required — `install/README.md` and the wiki Docker page (`bambuddy-wiki/docs/getting-started/docker.md`) both still accurately describe the behaviour.
-- **MakerWorld import/resolve/status fail under API-key auth even when the owner has a Bambu Cloud login (#1777, reported by @Mx772)** — The reporter (working on a browser extension that drives Bambuddy via `X-API-Key`) noticed that `POST /api/v1/makerworld/import` and `POST /api/v1/makerworld/resolve` returned `{"detail":"Downloading files from MakerWorld requires a Bambu Cloud login"}` even when the key's owning user had a valid stored Bambu Cloud session, and the same imports succeeded from the web UI. Root cause is exactly the shape the reporter traced: `require_permission_if_auth_enabled` in `backend/app/core/auth.py:1414` deliberately returns `current_user=None` for API-keyed callers — the comment at line 1408 makes this explicit and points at `cloud.py` for the resolver. The MakerWorld routes never got that resolver wired in, so `_build_service(db, None)` → `get_stored_token(db, None)` → no token → the "requires Bambu Cloud login" branch fires regardless of what the owning account has set up. Same shape #1182 fixed for cloud slicer presets, and the canonical fix for non-`/cloud/*` routes is already in the codebase as `resolve_api_key_cloud_owner` (cloud.py:128-160) — used by `slicer_presets.py:491` and `library.py:3871`. The MakerWorld routes were missing the wire-up. **Fix:** Three routes get the extra `api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner)` parameter — `get_status`, `resolve_url`, `import_instance` — and each resolves `cloud_token_user = current_user or api_key_cloud_owner` before calling `get_stored_token` / `_build_service`. `import_instance` additionally uses `cloud_token_user.id` for the `owner_id` argument to `save_3mf_bytes_to_library` (which translates to `LibraryFile.created_by_id`), so library rows imported via API key are now attributed to the key's owner instead of staying NULL. `/recent-imports` is unchanged — it only uses `current_user` as a permission gate (`_ = current_user`) and never touches the cloud token. The fix preserves fail-closed semantics for keys *without* the `can_access_cloud` flag: `resolve_api_key_cloud_owner` already fences on `api_key.user_id is not None and api_key.can_access_cloud` (cloud.py:158), so a key with only the per-route scope (`can_read_status` / `can_manage_library`) still surfaces the "requires Bambu Cloud login" error path — no new auth gap. **Two scope fields the API key needs:** the per-route scope (`MAKERWORLD_VIEW` → `can_read_status`, `MAKERWORLD_IMPORT` → `can_manage_library` per `_APIKEY_SCOPE_BY_PERMISSION` in `core/auth.py`) AND the orthogonal `can_access_cloud` flag (separate column on the `api_keys` table). The fix doesn't change that surface — it just stops dropping valid `can_access_cloud=True` keys on the floor. **Tests:** 6 new cases in `backend/tests/integration/test_makerworld_apikey_auth.py` pinning the full surface — API key with `can_access_cloud=True` + owner-has-token → `/status` reports `has_cloud_token=True`, `/resolve` builds the service with the owner User (asserted on the `_build_service` mock's call args), `/import` succeeds end-to-end and the resulting `LibraryFile.created_by_id` matches the API-key owner; API key with `can_access_cloud=False` → status still reports `has_cloud_token=False` (no widening) and import-row's `created_by_id` stays NULL; JWT-authenticated parity check confirms the existing user-session flow is unchanged by the added `Depends`. 6/6 new tests green; full backend suite (6157 tests) still green; ruff clean. No frontend change, no DB migration, no new permission, no new dependency. The reporter's browser extension and any other API-keyed Home Assistant / automation integration unblocks immediately on next deploy.
-- **Archive thumbnails missing for prints sliced via the docker sidecar (#1759, reported by @VID-PRO)** — The reporter (P2S) noticed every print sliced through Bambuddy's BS docker sidecar landed in the archive with no thumbnail, while the same model sliced from desktop Bambu Studio on their laptop showed the cover image. The "Some recent prints couldn't be archived with thumbnails" banner pointed at install step 4 (`Store sent files on external storage`) which is unrelated — that flag is set on FTP-fetch failures, not on missing-thumb in the sliced 3MF. Root cause is upstream of Bambuddy entirely: **neither the BambuStudio CLI nor the OrcaSlicer CLI renders `Metadata/plate_N.png` when invoked headlessly with `--slice --export-3mf`.** That render is a separate code path triggered by the `--export-png` flag, which is mutually exclusive with `--export-3mf` and additionally requires a working display backend (BS 02.07.x's bundled GLFW is hard-locked to Wayland — even `XDG_SESSION_TYPE=x11` + `GDK_BACKEND=x11` + `QT_QPA_PLATFORM=xcb` don't switch it back to X11, so an Xvfb display in the sidecar wouldn't help even if we wired a second-pass call). Confirmed empirically by feeding a thumbnail-stripped `Cube-MegaS.3mf` through both sidecars: both produced `.gcode.3mf` with zero PNG entries. The Orca sidecar has been silently shipping thumbnail-less 3MFs from STL inputs since it launched; nobody noticed until VID-PRO filed this against BS specifically. **Fix:** New `backend/app/services/plate_thumbnail.py` renders the missing thumbnails server-side after the slice returns. `inject_plate_thumbnails_if_missing(threemf_bytes)` parses the sliced zip, finds every `Metadata/plate_N.gcode` entry that doesn't have a matching `plate_N.png`, loads `3D/3dmodel.model` via trimesh, renders an isometric Bambu-green-on-dark view at 512×512 (`plate_N.png`) + 128×128 (`plate_N_small.png`) using the same matplotlib Agg pipeline as `stl_thumbnail.py`, and re-packs the zip with the PNGs injected. Visual style deliberately matches Bambuddy's existing library thumbnails — archive cards stay consistent inside Bambuddy rather than chasing parity with desktop Studio's plate render. Best-effort: input bytes are returned unchanged on any failure (no model file, trimesh can't parse, matplotlib render fails) so the slice flow itself can't fail because of a missing thumbnail. Idempotent: re-running on a previously-injected 3MF hits the no-op fast path and returns the input verbatim. Wired into both `backend/app/api/routes/library.py` slice paths (library-file slice at line 3593 + archive re-slice at line 3718) via `result = result._replace(content=inject_plate_thumbnails_if_missing(result.content))` immediately before `out_path.write_bytes(...)` — covers the cross-class merged-multi-plate path (`slicer_3mf_convert.merge_plate_3mfs`) automatically since merged bytes flow into the same write site. **Dependencies:** trimesh's 3MF loader imports `networkx` (scene-graph traversal) and `lxml` (model.xml parse) lazily inside the 3MF code path — both added to `requirements.txt` because they aren't strict trimesh transitives but the loader fails at runtime without them (`ModuleNotFoundError`). **Tests:** 7 new cases in `backend/tests/unit/services/test_plate_thumbnail.py`: input bytes returned unchanged (identity) when every plate already has a thumbnail (desktop-Studio fast path); both PNG sizes injected when missing; injected PNGs decode as 512x512 + 128x128 RGBA; multi-plate 3MF with one pre-existing thumbnail only renders the missing slots (pre-existing bytes preserved verbatim); 3MF with no `3D/3dmodel.model` returns input unchanged; non-zip input returns input unchanged; idempotent on second pass. **Verified end-to-end:** running `inject_plate_thumbnails_if_missing` against the actual BS sidecar and Orca sidecar outputs (`/tmp/bs-no-thumb-out.3mf` / `/tmp/orca-no-thumb-out.3mf` — both 25932/25992 bytes with zero PNG entries) produces 3MFs with valid `Metadata/plate_1.png` + `Metadata/plate_1_small.png` containing the rendered cube model (38.5% Bambu-green pixel coverage confirms the model is actually drawn, not a blank canvas). 6151/6151 backend tests still green; ruff clean. No sidecar Dockerfile change required — earlier experiments with Xvfb + `xvfb-run` in `Dockerfile.bambu-studio` were a false start (the BS GLFW Wayland lock means no X display can help) and have been reverted from the sidecar repo. No frontend change required — the archive UI already extracts `plate_1.png` from the sliced 3MF, the cards just had nothing to show.
-- **Local Presets page: deleted row stayed visible until refetch returned, allowing a second delete click → 404** — On the Slicer → Local Profiles page, clicking Delete → Confirm fired the `DELETE /api/v1/local-presets/{id}` request, then 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(['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_manager` → `printer_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.
-
-- **Chamber-fan badge shown on open-frame Bambu printers that have no chamber fan (reported off-list, screenshot of an A1)** — The Printers page rendered three fan widgets (part cooling / auxiliary / chamber) for every printer unconditionally at `PrintersPage.tsx::fanItems` (around line 3579), reading `cooling_fan_speed` / `big_fan1_speed` / `big_fan2_speed` off the status payload. Open-frame Bambu models (A1, A1 Mini, A2L, P1P) physically have no chamber fan — the firmware reports `big_fan2_speed` as 0 there, so the badge always rendered greyed-out and clicking it would let the user "set" a speed on a fan that doesn't exist. Closed-frame models (X1C / X1 / X1E / X2D / P1S / P2S / H2D / H2D Pro / H2C / H2S) are unaffected. **Fix:** new module-scope `MODELS_WITH_CHAMBER_FAN: ReadonlySet` allowlist near `mapModelCode`; the chamber entry is now spread into `fanItems` only when `MODELS_WITH_CHAMBER_FAN.has(printer.model ?? '')`. Open-frame printers drop to a 2-badge row (part + aux), which is what their hardware actually has. **Why an allowlist (not a denylist):** the existing enclosure-door-badge gate at `PrintersPage.tsx:3314` uses an explicit model list pattern; mirroring it keeps the file's classification convention consistent and means a new Bambu model added to the codebase has to be deliberately added to the chamber-fan set rather than silently inheriting (failure mode: missing widget on a real chambered printer, noticed immediately and trivially fixed) — better than the denylist's failure mode (phantom widget on a new open-frame model, looks correct, silently lies). The set deliberately excludes P1P: open-frame, no chamber fan, even though the door-badge list includes it (separate pre-existing inconsistency, not in scope to fix here). **Tests:** 5 new cases in `PrintersPage.test.tsx::'fan badges'` — hides on A1 Mini, hides on A1, hides on P1P, shows on X1C, shows on P1S. Each constructs a single-printer mock with the model under test plus a status payload that has all three fan speeds populated (53 / 53 / 53), then asserts the `title='Chamber Fan'` element is/isn't in the DOM and that the part-cooling / auxiliary badges still render unchanged so we're not over-filtering. Existing 51 PrintersPage cases still green; `npm run build` clean; ESLint clean. **Scope clarification — what this does NOT change.** The `/api/v1/printers/{id}/fan` backend route still accepts `fan=chamber` requests for all models (the dispatch surface stays uniform, so external automations / MQTT relays don't need a model-aware gate); the chamber-fan icon's behaviour and the `printers.fans.chamber` i18n string are unchanged on chambered models; the chamber-*temperature* widget is not affected (open-frame models' status payload doesn't include a chamber temp field, so it was already absent there).
-
-- **In-app "Install Update" on Windows installer installs failed with "Could not find git executable"** — Reported off-list by a Windows user running the `.exe` installer. Root cause has two layers, and the surfaced error only described the first. **Layer 1 — git not findable on Windows.** `backend/app/api/routes/updates.py::_find_executable("git")` falls through `shutil.which("git")` (the installer doesn't bundle git, fresh Windows installs have no git on PATH) into a fallback list of *Unix-only* paths (`/usr/bin/git`, `/opt/homebrew/bin/git`, `~/.local/bin/git` etc) — none exist on Windows, so the helper returns `None` and the route reports "Could not find git executable. Please ensure git is installed." **Layer 2 — no `.git` directory in the installer payload anyway.** `installers/windows/build.py::stage_backend` copies `backend/` via `shutil.copytree` rather than `git clone`, so even if Git for Windows were installed separately, the next `git fetch` would die on `fatal: not a git repository`. Adding Windows paths to `_find_executable` would only have changed which error message users saw. **Fix — distinct `update_method=windows_installer` instead of stretching the git path.** Same shape as the existing `docker` / `ha_addon` branches: detect the installer install, surface a download link to the release `.exe`, let the user run the installer like every other Windows app (Discord/Spotify model). New `_is_windows_installer_install()` helper returns True iff `sys.platform == "win32"` AND `settings.app_dir / ".git"` is absent — so a Windows developer with a real `git clone` keeps the git path, only installer users hit the new branch. New `_find_windows_installer_asset(release_data)` picks the matching asset out of the release's `assets[]` (prefer the versioned `bambuddy--windows-x64-setup.exe` because daily prereleases only publish that form; fall back to the unversioned `bambuddy-windows-x64-setup.exe` alias when present). `/updates/check` now includes `is_windows_installer: bool`, `update_method: "windows_installer"`, and `installer_download_url` on Windows-installer installs. `/updates/apply` short-circuits with `success: false, is_windows_installer: true` after the existing HA / Docker guards — defense in depth, since the frontend swaps the button so the POST shouldn't even fire on Windows. **Frontend.** `UpdateCheckResult` extended with `is_windows_installer`, `installer_download_url`, and `'windows_installer'` in the `update_method` union. `SettingsPage.tsx` inserts a new branch between the Docker snippet and the "Install Update" button: renders the same Bambu-green primary-button style as an `` pointing at `installer_download_url` (falls back to `release_url` then to the tag page so the link is never broken even if the release uploader is mid-publish). Hardcoded `data, settings and printers are preserved` reassurance in the body text — the installer preserves DATA_DIR by design, but Windows users who hit this UI mid-print would otherwise reasonably hesitate. `applyUpdateMutation.onSuccess` toast guard extended to treat `is_windows_installer` the same as HA / Docker, so a direct API call still surfaces the message instead of swallowing it. **i18n.** Two new keys — `settings.updateViaWindowsInstaller` (body copy) and `settings.downloadWindowsInstaller` (button label, takes `{{version}}`) — translated across all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5216 leaves per locale, no English fallback. **Tests.** Backend: 8 new cases in `test_updates_api.py` — `_is_windows_installer_install` (× 3: true on `.git`-less Windows tmp_path, false when `.git` exists, false off-Windows), `_find_windows_installer_asset` (× 3: prefers versioned, falls back to alias, None when missing), `/updates/apply` rejection for installer installs, `/updates/check` returns `update_method="windows_installer"` + the asset URL. Frontend: 1 new case in `SettingsPage.test.tsx::'shows the installer-download link for Windows installer installs'` asserting the ` ` is rendered with the correct `href` / `target=_blank` / `rel*=noopener` AND the in-app Update button is suppressed AND the Docker/HA messages don't leak across. All 29 backend updates tests + all 44 SettingsPage frontend tests green; `npm run build` clean; ESLint clean; ruff clean. **Scope — what this does NOT change.** No installer change (no git.exe bundled, no `.git` seeded — those would be heavier fixes that still wouldn't match the user mental model of "double-click installer = update"); no behaviour change for Docker / HA / Linux / macOS / Windows-git-clone installs (the existing `update_method` selection prioritises HA → Docker → Windows-installer → git in that order; only `.git`-less Windows installs reach the new branch); no auto-download or in-app installer launch (UAC + NSSM service stop/start ordering would need a separate elevated helper — out of scope for this fix).
-
-- **PrintModal printer picker no longer offers a printer between dispatch-accept and PRINT_START (reported off-list by a corporate user running multi-operator farm shifts)** — Operator picks a printer in the reprint modal, hits Send, Bambuddy accepts the dispatch and begins FTP upload + sending the print command. The printer hasn't reported `gcode_state=RUNNING` yet — it's still IDLE on its own MQTT status. A second operator opening the modal during this window sees the same printer as available and submits a second job. The backend correctly rejects the second submit with HTTP 409 (`background_dispatch._dispatch` rejects when `_queued_jobs` or `_active_jobs` already holds the printer_id), so no double-print is possible, but the operator only finds out after they click Send — wasted minutes per attempt on a busy floor. **Root cause:** `PrinterSelector.tsx::isPrinterBusy` consulted only `PrinterStatus.state` against `AVAILABLE_STATES = {IDLE, FINISH, FAILED}`. PRINT_START is the only signal that flips the printer out of IDLE, and there's a real wall-clock window (upload time + print command + firmware ack) between dispatch acceptance and that flip. The dispatch-queue state — already broadcast as a WebSocket `background_dispatch` push including `dispatched_jobs[].printer_id` and `active_jobs[].printer_id` — was being consumed by `ToastContext` for the progress overlay but never read by the picker. **Fix:** new `frontend/src/hooks/useDispatchedPrinterIds.ts` exposes `Set` of printer_ids with a queued or active dispatch, populated from the same `background-dispatch` window event the ToastContext listens for. Module-level singleton + `useSyncExternalStore` so every `PrinterSelector` instance sees the same snapshot and a modal opened mid-batch picks up the latest state without a refetch. Reference-stable snapshot (size + membership check) keeps `useSyncExternalStore`'s Object.is comparison from re-rendering on every WS push that doesn't change the set. `PrinterSelector.tsx::isPrinterBusy` ORs the set into the existing connected/state check — printer disabled the instant dispatch is accepted, re-enabled when the dispatch finishes (or fails) and disappears from the next state payload. `getPrinterStateLabel` returns `"Dispatching..."` for the badge so operators see the in-flight state instead of a misleading "Idle" on a now-disabled card. Hardcoded English label is consistent with the existing labels in that function (`"Idle"`, `"Printing"`, `"Paused"` are all hardcoded, no i18n key). **What this is NOT:** a backend change (the reservation Mike asked about already exists at `background_dispatch.py:283-290`); a behaviour change for `add-to-queue` / `edit-queue-item` modes (those don't set `disableBusy=true`, so the busy-OR remains dormant for the card click handler — the badge label still flips, which is informative); a guarantee against the WS-not-yet-connected race (a fresh page load that opens the modal before the WS initial-state push lands still sees an empty set for ~1 frame; same race as today, much shorter window). **Tests:** 8 new cases in `useDispatchedPrinterIds.test.ts` pin the contract — empty initial set, picks up `dispatched_jobs` printer_ids, picks up `active_jobs` printer_ids, unions both lists, clears when subsequent event reports zero jobs, ignores non-numeric `printer_id` (defensive against payload drift), reference-stable snapshot when content doesn't change, shared state across hook instances. Existing 84 PrintModal + PrinterSelector cases still green — the new code path is dormant until a `background-dispatch` window event fires, which existing tests don't trigger. `npm run build` clean, ESLint clean.
-
-- **Completion notification reported the whole project's duration and material usage when only one plate of a multi-plate 3MF was printed (#1785)** — Reporter (H2D) noticed the Discord on-print-complete message stated the full multi-plate project's totals (e.g. "6h 12m" / "370 g") even though only a single plate had been started, while every Bambuddy surface (print queue card, archive card, statistics) correctly showed the per-plate values. Root cause traced to the 3MF parser at `services/archive.py:200-264`, which sums per-plate `prediction` (slicer time estimate) and `weight` across every `` of a multi-plate file and stores those file-level totals on the `PrintArchive.print_time_seconds` / `PrintArchive.filament_used_grams` columns. That summing was added by #1593 to fix the archive card under-reporting on multi-plate files, and is correct for the archive-level "whole project" headline. The queue UI already re-reads the 3MF per-plate at `print_queue.py:272-285` (using `extract_filament_usage_from_3mf` / `_extract_print_time_from_3mf`) and substitutes the plate's actual values — which is why everything inside Bambuddy displays per-plate correctly. The notification path at `main.py::_background_notifications` read the archive's columns and `extra_data.filament_slots` directly with **no plate-aware override**, so the dispatched template variables (`{{duration}}`, `{{filament_grams}}`, `{{filament_details}}`) consistently rendered the project-wide sum. For material grams this was unconditionally wrong on multi-plate single-plate prints; for duration it depended on whether the real elapsed `actual_time_seconds` was populated (the `actual_time_seconds or print_time_seconds` fallback chain only landed on the summed estimate when the timestamps weren't usable). **Fix:** new `_scope_notification_archive_data_to_plate(archive_data, file_path, plate_id, status, progress, base_dir)` helper in `main.py` mirrors what the queue UI does — when `plate_id` is set on the just-completed print, re-read the 3MF, sum the plate's `` entries for the actual grams, read the plate's `` for the estimate, and replace `archive_data["actual_filament_grams"]` + `archive_data["print_time_seconds"]` + `archive_data["filament_slots"]` accordingly. `notify_plate_id` is captured from the existing `_print_plate_ids` register at the same point that already pops it (around `main.py:4183`), so no extra bookkeeping is added to the print-start path — the queue dispatcher and direct-Print path both already register plate_id there. **Partial-print scaling preserved:** the helper applies the same `progress / 100` scale factor to the per-plate grams + per-slot weights as the pre-existing summed-totals branch did, so a 50%-cancelled plate-2 print still reports "half the plate's grams," not the whole plate. **Fail-open on every error path:** missing `plate_id` (single-plate file / archive-only flow), missing `archive.file_path`, the 3MF file having been deleted between print completion and notification firing, a corrupt zip, or a plate index outside the file's range — all return `archive_data` unchanged so the notification still ships with the project-level numbers it would have shown before this fix. Same defensiveness shape as the helper-loaders the queue route relies on. **Hoisted `extract_print_time_from_3mf` into `utils/threemf_tools.py`** so the notification path can reuse the queue UI's logic without importing from a routes module (the route's `_extract_print_time_from_3mf` becomes a one-line alias). Identical signature + return shape, so the queue's existing call sites keep working without changes. **Tests:** 10 new cases. `test_threemf_tools.py::TestExtractPrintTimeFrom3mf` (7 cases): plate-N prediction returned for plate_id=N, first plate when no plate_id passed, None for plate_id outside range, None for unparseable prediction, None for missing slice_info / invalid zip / missing file. `test_notification_plate_scope.py::TestScopeNotificationArchiveDataToPlate` (10 cases): plate-2 of 3 collapses summed 370g/3h into plate's 120g/60min; plate-1 and plate-3 scope correctly; partial-print at progress=50 halves grams + per-slot weights but keeps full slicer estimate; no plate_id / no file_path / missing file / corrupt zip / out-of-range plate_id all return the input unchanged so the notification still sends; single-plate file with plate_id=1 is a clean no-op (the parser's sum already collapses to plate-1's values, no double-scaling). Full backend `pytest -n 30` 4086/4086 in 50s; ruff clean. **Scope clarification:** the archive card / project rollup / statistics surfaces stay on the summed totals (the original #1593 contract) — only the completion notification path now plate-scopes, mirroring the queue card precedent. Print Logs entries continue to use the per-run filament helper (#1378 / #1390) which already reads from `usage_results` + scales by progress, so this fix doesn't touch them.
+- **Floor pins for pydantic-settings ≥2.14.2 + msgpack ≥1.2.1** (`458bfa15`)
+- **Backend dependency security floor bumps + 422 constant rename** (`580f42c1`)
+- **dompurify 3.4.10 → 3.4.11 (GHSA-cmwh-pvxp-8882, moderate)** (`11227f65`)
+- **Vite 7 → 8 + plugin-react 5.2 (major bump)** (`f7620406`)
+- **Frontend dependency bumps** (`000af683`)
+- **Printer secrets restricted to update-authority callers** (`8283b175`)
## [0.2.4.7] - 2026-06-14
diff --git a/README.md b/README.md
index 4c3bece23..717da6d13 100644
--- a/README.md
+++ b/README.md
@@ -111,6 +111,20 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
---
+## 🧩 NEW: Slicer Pipelines — Save a Recipe, Reuse in One Click
+
+**Stop re-picking the same printer + process + filament + bed-type combination every slice.** Save a Slicer **Pipeline** once from the Slice dialog, then apply the whole bundle to any file with a single click — from File Manager, Archives, or MakerWorld imports.
+
+- 🧩 **One-click reuse** — A pipeline captures the entire Slice modal selection (printer + process + per-AMS-slot filaments + bed type) and surfaces as **Run with pipeline → \** on every sliceable row.
+- 🎯 **Specific printer or printer class** — Pin a pipeline to one printer, or to a *class* (e.g. *any X1C*) and let the queue scheduler pick the first available match. Identical-fleet farms get a single recipe instead of one-per-printer.
+- 🪢 **Multi-copy fanout** — Slice once, dispatch up to N copies. With class targeting the copies fan out across the matching printers in parallel — **Spread** (fastest wall-clock), **Single printer** (minimise colour-change overhead), or **First N** (one to each).
+- 📊 **Runs dashboard** — A new **Pipelines** tab on the Print Queue page lists every run with colour-coded status badges (queued / slicing / dispatching / in-progress / completed / partial-failure / failed / cancelled), per-copy detail on expand, filter dropdowns (Pipeline / Status / Target), and a **Retry failed** button that re-runs only the copies that didn't complete — successful copies are never re-printed.
+- 🔒 **Permission-gated** — Three permissions (`pipelines:read` / `pipelines:write` / `pipelines:run`) let you split authoring the recipe from spending filament with it.
+
+👉 **[Slicer Pipelines Guide →](https://wiki.bambuddy.cool/features/slicer-pipelines/)**
+
+---
+
## Why Bambuddy?
- **Own your data** — All print history stored locally, no cloud dependency
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index 55e1aff57..3e5284405 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -6,7 +6,7 @@ from pathlib import Path
from pydantic_settings import BaseSettings
# Application version - single source of truth
-APP_VERSION = "0.2.4.7"
+APP_VERSION = "0.2.4.8"
GITHUB_REPO = "maziggy/bambuddy"
BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")