# Changelog

All notable changes to Bambuddy will be documented in this file.

## [0.2.5b1] - Unreleased

### Added
- **Print labels: sort by colour as an alternative to spool-ID order (#1410, requested by @elit3ge)** — Reporter asked for an option to order the printed label sheet by colour instead of spool number so a multi-colour roll of Avery sheets / box labels groups related colours together physically. The label-render backend (`labels.py`) already honoured caller order — both `POST /inventory/labels` and `POST /spoolman/labels` preserve the order of `spool_ids` in the request body and pass it straight to the PDF renderer — so the fix is frontend-only. `LabelTemplatePickerModal` gains a small `sortMode` toggle ("By ID" / "By colour") rendered as a chip pair next to the material-filter row. The "by colour" mode converts each spool's `rgba` to HSL and returns a `[bucket, position]` sort key: chromatic colours (saturation ≥ 0.1) go in bucket 0 ordered by hue 0..360 so the sheet reads as a continuous rainbow; achromatic colours (greys, blacks, whites, plus missing/invalid rgba) go in bucket 1 ordered by lightness so the neutrals trail the rainbow black → white. Multi-colour spools sort on their primary `rgba` — the secondary `extra_colors` stripe still renders on the printed label but doesn't drive the sort, since multi-tone sorting would need a perceptual-distance model the use case doesn't justify. Stable tiebreaker on spool ID keeps identical-colour spools in a deterministic order across renders. The previous `[...selectedIds].sort((a, b) => a - b)` at submit time was forcing every PDF to ID order regardless of any frontend sorting — that's been replaced with `sortedSpools.filter(s => selectedIds.has(s.id)).map(s => s.id)` so the visible order flows through to the wire. Session-only state — toggle resets to "By ID" each time the modal opens, no persisted setting (label printing is a rare action and the user picks what they want every time). i18n: 3 new keys (`inventory.labels.sortBy.{label, id, color}`) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW); parity check holds at 4852 leaves per locale. Tests: two new in `LabelTemplatePickerModal.test.tsx` — one asserts the "By colour" toggle reorders the submit payload to `[Red, Ivory, Blue, Black]` (hue 0° / 33° / 240° then neutral with lightness 0) using the existing 4-spool fixture, the other guards the default "By ID" path so adding the toggle didn't quietly regress users who never click it. 17 modal tests green; frontend build clean.

- **Camera: in-app diagnostic for "Connection lost" (#1395 follow-up)** — Second step of the camera architecture overhaul. When the camera viewer hits its error state, a new **Diagnose** button next to **Retry** runs a staged check against the printer and renders the result inline: which stage failed, how long it took, and a translated remediation hint. Cuts off the "user opens a 'camera broken' ticket → wait days → ask for the support bundle → finally figure out it was their reverse proxy / LAN-only toggle / wrong access code" loop at the user's screen. **Backend** ships `backend/app/services/camera_diagnose.py` (orchestrator) and a new `POST /printers/{id}/camera/diagnose` route in `camera.py`. Stages: (1) `tcp_reachable` — opens a TCP socket to the camera port (322 RTSPS / 6000 chamber image) with a 3-second timeout; distinguishes timeout (`tcp_timeout` → "printer not reachable, check IP/network/power") from refused (`tcp_refused` → "camera port closed, check LAN-only and developer mode") from host-unreachable (`tcp_unreachable` → "printer not reachable"). (2) `first_frame` — captures one JPEG end-to-end via the existing `capture_camera_frame_bytes` pipeline (15-second timeout, same code that powers `/camera/snapshot`); auth, RTSP handshake, and first keyframe collapse into one stage because the user-facing answer is the same regardless of which sub-layer failed. **Live-stream shortcut**: when a viewer is currently watching the printer's camera AND the buffered last-frame timestamp is fresher than 10 seconds, the diagnostic skips the real test and returns `live_stream_active_healthy` — opening a fresh socket would kick the live viewer off on single-camera-connection firmwares (the #1348 reconnect-storm trigger), so we trust the real-world evidence instead. Response includes structured metadata for support triage: `protocol` (rtsp / chamber_image), `port`, `profile` (`default` or the model name with an override — currently only `P2S`), per-stage duration in ms, and the machine-readable summary code. **Frontend** adds `CameraDiagnoseModal.tsx` that fires the API call on mount, renders one row per stage with green-check / red-X / grey-skipped icons, and shows the summary remediation message in a bordered banner styled by overall status. The metadata line at the bottom (protocol / port / profile) lets support triage ask "what does your modal say?" instead of "send the support bundle". A **Run again** button re-runs the diagnostic without dismissing the modal. **EmbeddedCameraViewer** error state grows the Diagnose button (kept "Retry" as the primary action; Diagnose is the escape hatch for users who can't see what's wrong). A small stethoscope icon also lives in the viewer's always-visible control bar between **Refresh** and **Fullscreen**, so pre-flight testing ("did my firmware update break the camera?", "is the camera up before I send a print?") doesn't require waiting for the stream to fail first. Also lifted the previously-hard-coded "Camera unavailable" / "Retry" strings into `camera.unavailable` / `camera.retry` so the error UI is properly translated alongside the new keys. **i18n**: 16 new keys (`unavailable`, `retry`, plus `diagnose.{button,modalTitle,running,runFailed,retry,stage.*,summary.*,meta.*}`) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). German "Diagnose" is a real cognate — added to `IDENTICAL_TO_EN_ALLOWED.de` rather than translated to a synthetic. Parity check holds at 4849 leaves per locale. **Tests**: 11 backend unit tests in `test_camera_diagnose.py` cover the live-stream shortcut (skip when fresh, run when stale), the three TCP failure modes (timeout / refused / OSError) → distinct summary codes, the first-frame stage (no-frame and capture-exception cases), the all-OK path, and the result metadata (P2S → P2S profile / rtsp / 322; A1 → default / chamber_image / 6000; X1C → default / rtsp / 322). 1 backend integration test pins the route's response shape end-to-end. 3 frontend tests in `CameraDiagnoseModal.test.tsx` (mounted → API call, failure → translated remediation, Run again → re-call). 5021 backend tests + 1905 frontend tests green; ruff clean; build clean; i18n parity clean.

### Fixed
- **P2S camera: relaxed ffmpeg probe settings so the RTSP stream actually locks (#1395 follow-up, reported by @Tschipel)** — Reporter on a P2S running firmware 01.02.00.00 saw the camera connect for a few seconds and then time out, repeating. P1S on the same install worked fine because P1S uses the chamber-image protocol (port 6000), not RTSP — different code path. The P2S RTSP path was running ffmpeg with `-probesize 32 -analyzeduration 0`, tuned for X1/H2 fast startup. The P2S's slower keyframe pacing means ffmpeg can't lock onto the stream within 32 bytes; its own stderr literally says "Stream #0: not enough frames to estimate rate; consider increasing probesize". After ~2 s ffmpeg gives up, Bambuddy reconnects, the cycle repeats. The naïve "just bump probesize" patch would regress every other RTSP-capable printer, so the fix is also the first step of the camera architecture overhaul: per-model tuning lives in a new `backend/app/services/camera_profiles.py` registry instead of hard-coded module constants. `CameraProfile` dataclass holds the previously-global knobs (`probesize`, `analyzeduration`, `rtsp_reconnect_max`, `rtsp_reconnect_delay`, plus an `extra_ffmpeg_input_args` hook for future per-model flags); `get_camera_profile(model)` returns the model's profile or the default. The default profile preserves the historical X1/H2 fast-startup values verbatim — X1, X1C, X1E, X2D, H2C, H2D, H2D Pro, H2S all see no behaviour change. P2S gets the only override today: `probesize=1_000_000`, `analyzeduration=500_000` — enough room for the slow keyframe without adding multi-second startup latency. Internal SSDP codes (e.g. `N7` → P2S) resolve via an alias map so the camera path works during the early-connect window before the display name is settled. The two `_RTSP_MAX_RECONNECTS` / `_RTSP_RECONNECT_DELAY` module constants are gone in favour of `profile.rtsp_reconnect_max` / `profile.rtsp_reconnect_delay`; same defaults, but now overridable per model. Pattern is intentionally extensible — adding the next quirky model is a config entry in `_PROFILES`, not another global constant. **Tests:** 9 new in `test_camera_profiles.py` cover unknown model → default, `None`/empty → default, default preserves historical values, P2S has relaxed probe, P2S internal code (`N7`) resolves to P2S profile, lookup is case-insensitive, every other RTSP model still uses the default (so the next refactor regression is caught at unit-test time), profile is frozen (immutable). 58 existing camera-related tests still green; 5008 backend tests total green; ruff clean.

### Changed
- **Inventory: spool ID surfaced in the edit modal and the AMS filament hover card (#1385, contributed by @chanakyan-arivumani in #1402, reported by @pgladel)** — Reporter asked for the Spoolman / internal spool ID to be visible when editing a spool and when hovering the AMS-loaded filament tile, so the install can be cross-checked against the underlying spool row without opening Spoolman's UI separately. The data was already on the rendered components; only the rendering was missing. `SpoolFormModal` header now shows `#<id>` in muted monospace next to the "Edit Spool" title — but **only** in edit mode; copy and create paths don't surface an ID because no stable ID exists yet (a copy produces a new spool, and surfacing the source spool's ID there would mislead the user into thinking the new spool inherited it). `FilamentHoverCard`'s assigned-spool block shows the same `#<id>` inline with the brand/material/colour line; the existing `<p class="truncate">` is wrapped in a flex container with `min-w-0` on the parent and `shrink-0` on the new span so the truncation still kicks in on long names and the ID stays at full width. Inventory-mode parity holds without any branching — both internal and Spoolman spools carry an `id` with the same shape so the modal renders the right ID regardless of which inventory backend is in use. Tests: one regression in `FilamentHoverCard.test.tsx` (asserts `#42` renders in the assigned-spool block) plus three added in `SpoolFormModal.test.tsx` as post-PR work — edit mode shows the ID, create mode shows none, copy mode shows none. The copy-mode test is the load-bearing case: a future refactor that drops the `isEditing &&` guard would silently start leaking the source spool's ID into the Copy header, and now fails the test instead. 51 affected frontend tests green; frontend build clean.

- **Archives → Print Log: filename column expands to fit available width and wraps long names instead of clipping at 200 px (#1406, requested by @daFreeMan)** — Reporter on a 27" monitor saw long filenames like `Simple_Print_Monitor_-_ST7789_1.54_display_case_` truncated even though the table had plenty of unused horizontal space. The print-name `<span>` had a hard `truncate max-w-[200px]` cap that ignored viewport width entirely. Replaced with `break-words` + a `title` attribute, dropping the explicit max-width so the column auto-sizes to content. On wide screens the full name shows on a single line; on narrow ones it wraps inside the cell instead of forcing horizontal scroll. The `title` hover preserves the original tooltip affordance for the rare case where a really long name still gets truncated by viewport constraints. Frontend build clean; 23 ArchivesPage tests still pass.

### Fixed
- **Library "Open in Slicer": broken when the display name lacked `.3mf` or contained `/ \ ? #` (#1413, contributed by @benhalverson in #1416, reported by @ddingg)** — Reporter on Windows 11 / Chrome saw Bambu Studio and OrcaSlicer reject the slicer URL from the 3D-preview modal's "Open in Slicer" button with a parse error, even though downloading the same URL with curl worked. The MakerWorld "Save and open" path and the "Recent imports" entry both worked fine — different code path. Root cause: `GET /library/files/{file_id}/dl/{token}/{filename}` uses the URL-tail filename purely as a hint for the slicer to detect the file format from the path; the backend itself reads `file.filename` from the DB (`library.py:3806`) and ignores the URL segment. When `ModelViewerModal.handleOpenInSlicer` passed the modal `title` (display name like `"Mecha Mewtwo No AMS Multi Color Parted Statue"` — no extension) verbatim through `encodeURIComponent`, the resulting URL ended without `.3mf` and the slicer's client-side extension sniff refused to parse the response. The same path also let `/ \ ? #` through, which can survive `encodeURIComponent` (`/` is unencoded by spec) and break the slicer's URL parser separately. Fix adds a small `buildSlicerUrlFilename(filename)` helper to `frontend/src/api/client.ts` that strips `/ \ ? #` (replacing them with `_`) and appends `.3mf` when missing; `getLibrarySlicerDownloadUrl` now routes the filename through it before `encodeURIComponent`. The `.3mf` check is case-insensitive (`safe.toLowerCase().endsWith('.3mf')`) so `model.3MF` is handled correctly — a subtle improvement over the equivalent inline logic that's still present on the archive-side helpers `getArchiveForSlicer` and `getArchiveSlicerDownloadUrl` (call-site consolidation is a separate follow-up). Safe because `ModelViewerModal.tsx:264` already gates the button to `fileType === '3mf'` library files, so unconditional `.3mf` append never produces nonsense like `model.gcode.3mf`. Two new tests in `frontend/src/__tests__/api/client.test.ts` cover both branches (display name without extension → `.3mf` appended; display name with `/ ? #` → replaced with `_`). 21 client tests green; frontend build clean.

- **Add Spool modal: hex colour field can be typed into character-by-character again (#1407, reported by @anthonyma94)** — Pre-fix, after typing the first hex character the input's value snapped to e.g. "A00000" (the #1055 fix aggressively padded to 8 chars on every keystroke), the cursor jumped to the end, and the next keystroke landed at position 7 — which the original 7-char-truncation branch then dropped. Net effect: only the first character was ever typed; the rest stayed as zeros unless the user pasted a full hex code. Fix splits "what the user is typing" from "what gets sent to the backend": the input now has its own draft state holding 0–6 chars freely, and `updateField('rgba', ...)` only fires once the draft reaches a complete 6-char RGB (commits as `<6chars>FF`). On blur, a partial 1–5 char draft is right-padded with `0` and committed so the form state always carries a valid 8-char rgba — preserves the #1055 invariant that the backend never sees a malformed value, without re-introducing the truncate-on-keystroke trap. A `useEffect` keeps the draft in sync when an external action (the colour picker, a swatch click, edit-mode load) changes the canonical hex. Paste of 7-/8-char strings truncates to the leading RGB triplet: Bambu filaments are opaque and the UI never exposed an alpha affordance, so dropping the (undocumented) "paste with alpha" case is fine. The existing `ColorSectionHexInput.test.tsx` was rewritten to match the new contract — 8 tests covering both new behaviours (draft reflects each keystroke, no commit while partial, commits on length 6, blur-padding for partials, no commit when cleared then blurred) and the kept #1055 invariants (committed rgba is always 8 hex chars, 7-/8-char paste truncates, non-hex chars stripped). 51 spool-form frontend tests green; frontend build clean.

- **Virtual Printer queue: timelapse / bed-leveling / flow-cali / vibration-cali / layer-inspect now inherit the slicer's choice instead of always falling back to global defaults (#1403, reported by @pwostran)** — Reporter sliced in OrcaSlicer with timelapse enabled, sent to a VP queue, started the job from the queue and got no timelapse video. The dispatch chain itself was correct (queue item → scheduler → MQTT command honours `timelapse`); the gap was at queue-add time: the VP's `_add_to_print_queue` read `default_timelapse` from settings (introduced in #1235 to stop column defaults from winning), but ignored the slicer's project_file MQTT command entirely. The slicer's choice — which Bambu Handy / Bambu Studio / Orca all surface in their "Print options" dialog and ship in the MQTT payload as `timelapse: true|1` — was being thrown away. So a user with `default_timelapse=false` (the new-install value) would have to either flip the global setting or manually edit every queue item, even though their slicer's UI was clearly saying "record timelapse for this job". Fix: `on_print_command` in the VP manager now stashes the slicer's project_file dict keyed by filename, and `_add_to_print_queue` waits up to 2 s for that capture before reading the settings fallback. Each option flows through per-field — slicer value wins if present, else the existing settings default (so users who explicitly set `default_timelapse=true` in their VP workflow card still get that on slicers that don't send a print command). MQTT field naming is preserved exactly: `bed_leveling` (single L) on the wire stays mapped to `bed_levelling` (double L) on the Bambuddy column. Integer 0/1 from H-family slicers and bool true/false from P1/X1 slicers both coerce correctly via `bool()`. Wait is skipped when there's no MQTT server attached to the VP instance (covers unit tests calling `_add_to_print_queue` directly so they don't pay the 2 s tax) and capture is consumed on use so the dict stays bounded across many prints. Two new regressions in `test_virtual_printer.py::TestPrintQueueMode`: `test_add_to_print_queue_inherits_slicer_print_options` (slicer=True overrides settings=False across all 5 fields; capture is consumed) and `test_add_to_print_queue_coerces_slicer_integer_zero_one` (H-family integer payload is coerced). The existing `#1235` test (`test_add_to_print_queue_uses_workflow_defaults_from_settings`) still passes because the no-MQTT-attached gate skips the wait, so the settings fallback path is preserved when no slicer capture exists. **Plus two side-bugs surfaced while investigating Martin's "modal not respected" hypothesis** — the support-package evidence cleared the reprint modal (46 `print_scheduler` and 33 `background_dispatch` events with `timelapse: true` shipped to real P1S printers, end-to-end working), but the same dig turned up two latent issues worth fixing in the same pass: (a) **`POST /webhook/printer/{id}/start` was broken on four axes** — `await printer_manager.start_print(...)` against a `def` (not `async def`) function, `queue_item.archive_id` (int) passed as the `filename` arg, `printer_manager.get_status(...).get(...)` against a `PrinterState` dataclass (not a dict), and every print option discarded (timelapse, bed_levelling, AMS mapping). The route would 500 before ever reaching the printer. Rewritten to mirror `POST /print-queue/{item_id}/start`: just clear `manual_start=False` on the next pending queue item and let the scheduler dispatch it with the queue's stored options intact. Three new regressions in `test_webhook_start_print.py` (clears `manual_start`, preserves stored print options, 404 when no pending items / unknown printer). (b) **`vibration_cali` default drift in `background_dispatch.py`** — `ReprintRequest.vibration_cali` and `FilePrintRequest.vibration_cali` both default to `True` (matches Bambu Studio behaviour for X1/P1 series), but the two `_process_job` call sites read `job.options.get("vibration_cali", False)`. Cosmetic today because the frontend always sends the field, but a latent landmine for any future caller that bypasses the schema (e.g. an internal dispatcher seeding options programmatically). Both call sites flipped to `True`; new contract test `test_dispatch_option_defaults_align_with_request_schema_defaults` introspects the source to lock the alignment for all six print-option fields so this drift can't recur. 116 VP unit tests green; 4999 backend tests green; ruff clean.

- **Inventory: "Reset usage to 0" no longer inflates remaining weight back to label_weight (#1390 follow-up, reported by @IndividualGhost1905)** — Reporter reset a 544 g spool's consumed counter and watched its displayed remaining jump to 1000 g — exactly the opposite of what the dialog promised ("Spools and remaining weights are not changed"). Root cause was an architectural conflation in the internal inventory model: a single `weight_used` column was doing two jobs, the resettable "consumed since tracking started" stat AND the basis for the displayed remaining (`label_weight - weight_used`). Zeroing it correctly cleared the stat but unavoidably reset remaining to full. Spoolman has separate `used_weight` and `remaining_weight` fields, so its API call was correct, but Bambuddy's frontend was *also* computing remaining as `label_weight - weight_used` for Spoolman spools (ignoring Spoolman's real `remaining_weight` field), so the same visual bug bit in Spoolman mode too. **Internal mode fix:** new `weight_used_baseline` column (Float, default 0) on the `spool` table; the "Total Consumed" display is now `weight_used - weight_used_baseline` clamped to ≥0; the reset endpoints stamp `baseline = weight_used` and leave `weight_used` untouched, so remaining (= `label_weight - weight_used`) is preserved. Subsequent prints continue to grow `weight_used` and the resettable counter naturally tracks the post-reset delta. **Spoolman parity fix:** `_map_spoolman_spool` now reads Spoolman's `remaining_weight` field and returns a synthetic `weight_used = label_weight - remaining_weight` so the frontend's remaining calc matches Spoolman's real stored value; `weight_used_baseline` is computed as `synthetic_weight_used - real_used_weight` so `weight_used - baseline` equals Spoolman's `used_weight` (the resettable counter). After a Spoolman reset (real used_weight=0, real remaining_weight=544) the user sees consumed=0 and remaining=544 — identical to internal mode. Also fixed a related Spoolman bug: editing a spool's metadata after a reset would PATCH Spoolman with `remaining_weight = label - used_weight = 1000`, overwriting the real 544 g; `update_spool` now derives the default `weight_used` from Spoolman's `remaining_weight` instead of `used_weight` so non-weight edits preserve the existing physical state. Frontend `totalConsumed` aggregate in `InventoryPage` and the three "consumed" displays in `ForecastPanel` (delta-rate, per-SKU totalUsedG, per-spool consumed cell) all switched to `Math.max(0, weight_used - (weight_used_baseline ?? 0))`. The `?? 0` fallback keeps pre-migration installs rendering correctly until `init_db()` runs the idempotent `ALTER TABLE spool ADD COLUMN weight_used_baseline REAL DEFAULT 0` (works on both SQLite and Postgres). **Tests:** `test_spool_reset_usage.py` rewritten — old asserts that the endpoint zeroed `weight_used` now assert it stamps baseline = weight_used and leaves weight_used alone, plus a new `test_reset_then_print_advances_only_the_counter` test that simulates a 50 g print after a reset and confirms `consumed=50, remaining=494` (i.e. remaining keeps decrementing across the reset). `test_spoolman_inventory_helpers.py` gets two new tests on the mapper covering pre-reset and post-reset Spoolman shapes. `test_spoolman_inventory_api.py::test_reset_spool_usage` updated to assert the new InventorySpool contract (consumed=0, remaining=750, baseline absorbs the reset). 4993 backend tests green; ruff clean; frontend build clean. Per the inventory-parity rule saved last session: both modes now ship the same UX, both call sites verified end-to-end before declaring done.

- **Adding a printer with a wrong access code (or unreachable IP) no longer creates an empty card** — Several support reports traced back to a single root cause: the user mistyped their access code in the Add Printer dialog, `POST /printers/` happily persisted the row, the subsequent `printer_manager.connect_printer()` call was fire-and-forget so the failure was invisible, and the dashboard ended up showing a printer card that could never display state. The create route now runs `printer_manager.test_connection()` (the same MQTT probe the standalone Test Connection button has always used) BEFORE inserting the row, and refuses with HTTP 400 if the probe fails. The Printer row is never written on failure. **Structured error response**: backend returns `{detail: {code: "printer_connection_failed", message: "..."}}` rather than a plain English string — the new `ApiError.code` field on the frontend lets the toast layer pick a localized `printers.toast.connectionFailedNotAdded` key instead of surfacing the English fallback. **Existing tests** kept green via an autouse `_mock_printer_test_connection` fixture in `test_printers_api.py` that defaults the probe to success; a new `test_create_printer_rejects_when_mqtt_probe_fails` asserts the failure path returns 400, surfaces the stable code, AND verifies the row was not persisted (the critical part — earlier versions of the regression would have passed even if we'd left the row behind). 8 new i18n translations for `printers.toast.connectionFailedNotAdded` across all 8 locales; parity holds at 4831 leaves. 28 printer-route tests green.

### Changed
- **GitHub backup: save-failure messages render inline on the card instead of as a toast** — The new "repository is not private" rejection message is ~250 chars listing every credential the backup carries, which clips badly in a toast. Both the initial-setup save and the debounced autosave now stash the backend's error message into a new `saveError` state and render it as a red inline banner above the test-result block, with `whitespace-pre-wrap` so the full message stays readable. The banner clears on a successful save, on the next save attempt, and as soon as the user starts editing the URL / token / provider (the three fields whose changes invalidate the privacy check) — so it doesn't linger after the user has already addressed the cause. Short success toasts ("Settings saved", "Token updated", "Backup enabled") are unchanged. Manual dismiss button included for users who want to clear it without retrying.

### Security
- **GitHub backup refuses to save against a non-private repository** — While auditing real-world Bambuddy backup repos on GitHub I found several that were left public by their owners. That's a serious data leak: the settings backup only filtered `bambu_cloud_token` and `auth_secret_key`, so `mqtt_username`, `mqtt_password`, `ha_token`, `prometheus_token`, `bambu_cloud_email`, `external_url`, and the printer access codes (via K-profiles, which carry the serial number) were going to whatever visibility the user picked when they created the repo. Fix is a hard guard at every save and re-checked on every push: **`POST /github-backup/config` and `PATCH /github-backup/config`** (when the URL, token, or provider changes) run a connection test internally and return HTTP 400 unless `is_private` comes back True. Same check fires inside `run_backup()` before every scheduled or manual push, so a repository that was private at config time but later flipped to public in the provider's UI gets a clear "Backup aborted: the target repository is no longer private" failure entry instead of leaking the next backup. Implementation: each provider's `test_connection` (`GitHubBackend`, `ForgejoBackend` override, `GitLabBackend` override; `GiteaBackend` inherits unchanged) now returns `is_private: bool | None` — `True` for confirmed private, `False` for public (or GitLab's `internal`), `None` for "couldn't determine" (older self-hosted APIs, non-2xx responses). The route helper `_enforce_private_repo` rejects anything that isn't `True`, with separate error messages for the public case ("Make the repository private...") vs the unknown-visibility case ("...could not confirm..."). **Frontend** test-connection UI now renders the visibility result inline — green check + "Repository is private — safe to back up to" when confirmed, red banner with the full list of credentials at risk + "Saving is blocked until..." when public, yellow banner + "could not determine" when null. Three new i18n keys (`repoIsPrivate`, `repoIsPublicWarning`, `repoVisibilityUnknown`) translated across all 8 locales; parity holds at 4830 leaves. **Wiki** `docs/features/backup.md` gains a top-level `!!! danger "Private repositories only"` block listing what's at stake and what to do if the user already has a public backup repo, plus every per-provider setup step is updated from "(can be private)" to "(**must be private**)". **Tests**: 5 new in `test_github_backup_api.py::TestGitHubBackupPrivateRepoGuard` — create rejects public (400 + "not private" in detail), create rejects unknown visibility (400 + "could not confirm"), create rejects failed test_connection (400 + propagates the underlying message), PATCH that changes the URL re-runs the check and rejects on public, PATCH that touches an unrelated field (e.g. `schedule_enabled`) does NOT call `test_connection` (proven via a mock that raises if called — without the field-change gate, every benign toggle would trigger a live API call). The existing 15 tests now use an autouse fixture that mocks `test_connection` to return private-success so they don't try to reach github.com. 4905 backend tests green.

### Fixed
- **Spoolman edit-spool: editing a spool no longer mints duplicate filaments in the Spoolman catalogue (#1357 follow-up, reported by @pgladel)** — After the initial #1357 close, the reporter showed that BB was still spawning new Spoolman filament rows on every subsequent edit. The previous fix taught `find_or_create_filament` to bridge the AMS-sync name shape (`"Glow"`) with the user-edit shape (`"PLA Glow"`), but only on the *find* path — the moment the user changed any field that fed the match key (subtype/material/brand/color_hex) the lookup missed and a brand-new filament was created, the spool was re-linked to it, and the previous filament was orphaned. Repeating the loop produced the spread the reporter screenshotted (IDs 126/127/128/129 all "Amazon Basics / PLA Glow / PLA", slight color variants). Root fix is a behaviour change in `PATCH /spoolman/inventory/spools/{id}`: before calling `find_or_create_filament`, the route now computes whether the desired metadata still matches the current linked filament and, if so, skips the lookup entirely (a no-op metadata edit — just `note` or `weight_used` — never touches the filament catalogue). When metadata IS changing it consults a new `SpoolmanClient.is_filament_shared(filament_id, exclude_spool_id)` helper: if the current filament is a *singleton* (only this spool points at it, archived spools included so a sibling-archive doesn't fake singleton-ness), the route PATCHes that filament in place via `patch_filament` — `name`, `material`, `color_hex`, `weight`, plus a `vendor_id` resolved via `find_or_create_vendor` when the brand changed. Only when the filament is genuinely shared with another spool does the route fall back to the legacy `find_or_create_filament` path, because PATCHing a shared filament would silently rewrite every sibling spool's metadata. Net effect mirrors internal-inventory behaviour ([[feedback_inventory_modes_parity]] saved this session): editing a spool updates the thing the spool already points at, instead of proliferating new entities. Three new tests in `test_spoolman_inventory_api.py::TestSpoolmanInventoryCRUD` cover the new contract: a no-op metadata edit (only `note`/`weight_used`) does NOT call `find_or_create_filament` OR `patch_filament`; a subtype change against a singleton filament calls `patch_filament(7, {...name: "PLA Matte"})` and NOT `find_or_create_filament`; the same change with `is_filament_shared` mocked to True falls back to `find_or_create_filament` and does NOT call `patch_filament`. 162 spoolman-inventory tests + 192 broader spoolman tests green; ruff clean.

- **Inventory: "Print labels…" now works in Spoolman mode** — Both endpoints already exist (`POST /inventory/labels` for the built-in table, `POST /spoolman/labels` for Spoolman), and the `LabelTemplatePickerModal` correctly branches on a `spoolmanMode` prop. But the modal was instantiated in `InventoryPage.tsx` with `spoolmanMode={false}` hard-coded, with a stale comment from the original PR claiming "Spoolman path hands users an iframe straight to Spoolman so the per-spool button never shows in that context". That assumption stopped being true when the unified inventory UI shipped — the per-spool button DOES show in Spoolman mode now, but every click resolved to `/inventory/labels` with Spoolman spool IDs and returned `404 Spool(s) not found`. Fix passes the actual `spoolmanMode` value through to the modal (one-line change, plus removing the stale comment block). The existing `LabelTemplatePickerModal.test.tsx` already covers both branches at the component level — the gap was that no test exercised the InventoryPage wiring. This is another instance of the parity rule from [#1390 follow-up]: inventory features must ship the same UX in both modes; per the new feedback memory, any future inventory change gets a mental checklist of both routes + both client methods + both UI gates before being considered shipped.

### Added
- **Inventory: "Reset usage to 0" also works in Spoolman mode (#1390 follow-up)** — The first cut of this action only wired the built-in inventory path, so Spoolman users saw the eraser icon disappear when they switched modes. Now the same two endpoints exist on the Spoolman inventory router: `POST /spoolman/inventory/spools/{spool_id}/reset-usage` PATCHes Spoolman's `/spool/{id}` with `used_weight: 0` for a single spool, `POST /spoolman/inventory/spools/reset-usage-bulk` does the same per ID across an explicit list and returns `{reset: N}` (individual Spoolman failures are logged and counted out, the batch keeps going). A `reset_spool_usage(spool_id)` helper on `SpoolmanClient` is the actual HTTP call. The mutations in `InventoryPage.tsx` already had the right shape — they now switch on `spoolmanMode` to pick `api.resetSpoolmanInventorySpoolUsage` / `api.bulkResetSpoolmanInventorySpoolUsage` vs the internal-inventory client methods, and the three `spoolmanMode ? undefined : ...` gates that hid the eraser buttons in Spoolman mode are gone. Three new tests in `test_spoolman_inventory_api.py` lock the Spoolman path (per-spool, bulk, and the typo-wipe guard on empty list). The wiki page now says "Spoolman users get the same actions" instead of the original "Spoolman-mode users don't see either button" note. 4900 backend tests green.
- **Inventory: "Reset usage to 0" per spool and across all active spools (#1390 follow-up, requested by @IndividualGhost1905)** — Each spool's `weight_used` counter accumulates over its lifetime and feeds the "Total Consumed (Since tracking started)" stat on the Inventory page. There was no way to clear it without nuking the spool or manually editing the field — and manually setting `weight_used=0` via PATCH /spools/{id} auto-locks the spool (`weight_locked=true` is auto-set whenever `weight_used` is sent explicitly, so AMS auto-sync stops touching the spool), which is the wrong behaviour for "clean-slate my Total Consumed stat so future prints track from zero". **Two dedicated endpoints** in `backend/app/api/routes/inventory.py` zero the counter without touching the lock flag: `POST /inventory/spools/{spool_id}/reset-usage` (single spool) returns the updated `SpoolResponse`; `POST /inventory/spools/reset-usage-bulk` (`{spool_ids: [int, ...]}`) returns `{reset: N}`. The bulk endpoint rejects empty / missing `spool_ids` (HTTP 400) — no wildcard / "reset-all" shortcut, since a typo there would wipe the entire inventory's tracking; the caller must explicitly pass the list. Both leave `weight_locked` alone: if the user had locked the spool, the lock stays; if it was unlocked, it stays unlocked and the next AMS sync picks up from zero. **Frontend** adds two affordances: a small eraser icon button on the "Total Consumed" stat card (visible only when there's actually usage to reset AND we're not in Spoolman mode) that opens a confirm modal explaining what the reset clears and that the spools / remaining weights are not changed, and an eraser icon in each table row's action column (visible only on active spools with `weight_used > 0`, hidden in Spoolman mode since Spoolman manages its own usage accounting). Both routes share the same `ConfirmModal` infrastructure as delete/archive — `confirmAction` state now covers `'delete' | 'archive' | 'reset-usage' | 'reset-all-usage'`. **i18n**: 10 new keys (`resetUsage`, `resetUsageTooltip`, `resetUsageConfirm`, `resetAllUsage`, `resetAllUsageTooltip`, `resetAllUsageConfirm`, `usageReset`, `allUsageReset`, `resetUsageFailed`, plus `resetUsage` reused as confirm button label) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). Parity check holds at 4827 leaves per locale. **Tests**: 8 new regressions in `test_spool_reset_usage.py` cover per-spool reset zeroes `weight_used`, per-spool reset does NOT auto-lock, per-spool reset preserves an existing lock, 404 for missing spool, bulk reset zeroes only listed spools (untouched spools keep their usage — the typo-wipe guard), bulk reset rejects empty list (400), bulk reset rejects missing `spool_ids` field (400), bulk reset preserves `weight_locked` across mixed locked/unlocked targets. 4897 backend + 1901 frontend tests green.

### Changed
- **Settings → Filament: "Spool Catalog" now shows the same UI in Spoolman mode as in internal-inventory mode** — Previously, switching to Spoolman mode hijacked the Spool Catalog card and replaced it with a Spoolman filament list (Vendor — Name / Material / Weight / Spool Weight) with inline edit for name + spool_weight. Two separate concepts had been merged into one card: a Bambuddy-local **spool tare catalog** (the actual purpose of the card — name + weight definitions used to compute spool tare) vs a **filament editor** for Spoolman's `Filament` entity. The filament-editor view replaced the spool tare table entirely in Spoolman mode, with no way to see or manage the spool catalog. Now the card always renders the local Spool Catalog (Add / Edit / Delete / Export / Import / Reset / bulk-delete) regardless of inventory mode. The Spoolman-filament inline editor is removed — Spoolman users edit filament name / spool_weight in Spoolman's own UI. Side effect of the rewrite: the noisy `GET /api/v1/spoolman/inventory/filaments → 400 Bad Request` that fired on the Filament settings page even when Spoolman is disabled is gone, because the component no longer issues the probe at all. Files affected: `frontend/src/components/SpoolCatalogSettings.tsx` (rewrite, ~750 → ~445 lines), `frontend/src/components/SpoolWeightUpdateModal.tsx` (deleted — only used by the removed editor), test file rewritten to match the simplified component. No backend changes — `PATCH /spoolman/inventory/filaments/{id}` route still exists for API consumers, just no longer wired to a UI.

### Fixed
- **Stats page widgets now match Quick Stats — every panel reads per-event data (#1390 follow-up, reported by @IndividualGhost1905)** — After #1378 moved Quick Stats and the run aggregates to `print_log_entries`, six widgets (Filament Used, Filament Cost, Filament Trends, Printer Stats By Weight / Time, By Material, Color Distribution) plus Failure Analysis still iterated the archive list. Two divergences fell out of that split. **Reprints**: each reprint of an archive adds a new `print_log_entries` row but the `print_archives` row gets overwritten in place, so event-based widgets counted N reprints while archive-based widgets counted 1. **Hard-deleted archives**: the foreign key is `ON DELETE SET NULL`, so the event survives as an orphan (`archive_id=NULL`) — Quick Stats kept counting it, archive-iterating widgets couldn't see it. The reporter's test server (14 archives / 52 events / 29 orphans confirmed by the diagnostic query) made the split very visible. Fix swaps the data source in two places: (1) `GET /archives/slim` (the only frontend caller is StatsPage, so every widget that consumes the `archives` query gets the per-event data in one step) now reads from `PrintLogEntry`, LEFT JOINs `PrintArchive` for the sliced `print_time_seconds` estimate (null for orphans, and downstream widgets already fall back to `actual_time_seconds` / `duration_seconds`), uses `PrintLogEntry.duration_seconds` as the authoritative measured-time field when present (the original computed-from-started/completed_at path is kept as the fallback so legacy event rows from pre-#1378 still surface time), and returns `quantity=1` per event since per-event semantics make the archive-level quantity multiplier meaningless (verified no StatsPage widget actually reads `quantity` — `grep -n "\\.quantity" frontend/src/pages/StatsPage.tsx` returns nothing); (2) `FailureAnalysisService` switched from `PrintArchive` to `PrintLogEntry` for every aggregation (totals, by reason, by filament, by printer, by hour, recent failures, weekly trend) — `project_id` filtering still resolves through the archive table (events don't carry a direct project link) but counts the matching events, not the archives. The conftest `archive_factory` already synthesizes a matching `PrintLogEntry` per archive (added when #1378 landed), so existing tests stay green; one small tweak there now syncs the synthesized event's `created_at` with the archive's so date-range filtered tests don't lose the event to `server_default=func.now()`. Three new regressions in `test_archives_api.py`: `test_slim_counts_reprints_as_separate_rows` (three reprints → three slim rows → 3× filament summed correctly), `test_slim_includes_orphan_events` (archive deleted, event survives, slim still returns it with `print_time_seconds=null`), `test_failure_analysis_counts_reprints_and_orphans` (a reprint of a failed archive + an orphan failed event both contribute to `failed_prints` and `failures_by_reason`). One existing assertion updated — the `test_slim_returns_only_expected_fields` test was asserting `quantity == 2` from an `archive_factory(..., quantity=2)` call, which no longer rounds-trips through the per-event endpoint; updated to `quantity == 1` with a comment pointing at the semantic shift. 4889 backend tests green, 31 StatsPage frontend tests green, ruff clean.
- **FTP upload no longer silently treats 426 "Failure reading network stream" as success (#1401, second root cause reported by @iitazz)** — Looking at the support bundle from @iitazz showed every FTP upload to their P2S (firmware 01.02.00.00) ending the same way: data channel sendall completes in ~200 ms at an impossibly high "speed" (7+ MB/s for files the printer can only actually receive at ~1–2 MB/s), then voidresp returns `426 Failure reading network stream. (error_temp)` from the printer, and Bambuddy proceeds — `WARNING FTP STOR confirmation not received for X (proceeding): 426 ...` followed immediately by `INFO FTP upload complete`. The print command then gets dispatched, the printer tries to parse what's actually a partial 3MF (the reporter's downloaded-from-printer 3MF was 458752 bytes — exactly `7 × 65536`, our FTP chunk size — for a 668025-byte source), and surfaces the "unable to parse 3mf file" error the reporter sees. Two stacked failures: a P2S firmware / TLS-data-channel quirk that severs the FTP data stream mid-transfer (separate investigation; #1401 doesn't fix that), AND the voidresp handler in `backend/app/services/bambu_ftp.py` swallowing the resulting 426 because the original comment assumed *"the data was fully sent so the file is likely on the SD card"* — true for socket-level timeouts where we just didn't HEAR the 226 in time (H2D needs 30+ s tolerance and we want to keep that), false for `426` where the printer is explicitly telling us *the data stream itself was cut*. Fix splits the broad `except Exception` into two branches: `except ftplib.Error` (covers `error_reply`, `error_temp`, `error_perm`, `error_proto` — the server *responded* with a failure on the control channel) logs at ERROR and re-raises, so the outer `except (OSError, ftplib.Error)` returns False and the dispatcher sees a real upload failure instead of green-lighting a print of a truncated file; `except Exception` keeps the existing proceed-with-warning behaviour for socket timeouts so the H2D 30-second voidresp tolerance survives. Same split applied to `upload_bytes()` since it had the same `except Exception: pass` shape. The reporter will still hit the underlying 426 (we haven't fixed the P2S transport problem yet — that's separate), but they'll now see an upload failure surfaced honestly rather than a confusing parse error 30 seconds into the print attempt. Tests: two new regressions in `TestUpload` patch `_ftp.voidresp` to raise `ftplib.error_temp("426 ...")` and assert both `upload_file()` and `upload_bytes()` return False. 18 upload-related tests green. The earlier-this-section validation fix is unrelated and stays — it still catches genuinely raw `.gcode` files at the upload step.
- **Upload validation rejects unprintable 3MF / raw-gcode files at the upload step instead of letting them fail at the printer (#1401, reported by @iitazz)** — Reporter sliced in OrcaSlicer, uploaded the result to Bambuddy, clicked Print, and the printer rejected with "Printing stopped because the printer was unable to parse the 3mf file" — every time, for multiple files, on both library uploads and SD-card-browsed files. Trace through the support bundle showed: (a) the stored library file ended in `.gcode` (not `.gcode.3mf`), and (b) `background_dispatch.py` constructs the FTP destination filename by appending `.3mf` when the source doesn't already end in `.gcode.3mf` / `.3mf` — so raw gcode gets shipped to the printer named `whatever.gcode.3mf` and the firmware's 3MF parser chokes on the missing zip header. The same shape also manifests as `Failed to parse plates from archive ... File is not a zip file` warnings on Bambuddy's side. Whether the user manually re-extensioned a file or their slicer saved as `.gcode` instead of `.gcode.3mf`, the right place to catch this is the upload, not the printer 30 seconds later. **New `validate_print_file_upload()` helper** in `backend/app/api/routes/library.py` runs two checks: (1) reject any filename ending in `.gcode` (but not `.gcode.3mf`) with a clear message — "Raw .gcode files can't be printed on Bambu printers in network mode — they need a .gcode.3mf zip container (gcode plus metadata). Re-export from your slicer and make sure the file ends in '.gcode.3mf', not just '.gcode'. If your OS hides extensions, double-check the file with the extension visible." (2) For any filename ending in `.3mf` (incl. the compound `.gcode.3mf`), verify the file body starts with `PK\x03\x04` (ZIP magic bytes); reject otherwise with a message pointing at the slicer's "Export Plate Sliced File" action. Suffix-based check rather than `os.path.splitext` because compound extensions like `.gcode.3mf` show up as just `.3mf` after splitext — both must trigger the same validation. **Applied to every relevant upload route**: `POST /library/files` (covers File Manager upload AND the printer-card drag-drop, which routes through the same endpoint), `POST /archives/upload` (single archive), `POST /archives/upload-bulk` (rejects bad files per-row instead of aborting the batch — one bad file in a 10-file drag-drop doesn't lose the other nine), `POST /archives/{archive_id}/source` (per-archive source 3MF), `POST /archives/upload-source` (slicer-post-processing match-by-name). Validation runs AFTER `_resolve_upload_destination` so folder-permission rejections (403 readonly, 400 missing-path, 409 collision) still take precedence — preserves existing error ordering. STL / image / other non-print uploads bypass the validator entirely; Bambuddy is also a library, not just a print dispatcher. **Frontend visibility fix** in `FileUploadModal.tsx` (same component used by File Manager + Printers page + Archives): the modal auto-closed after `setIsUploading(false)` regardless of per-file results, so a 400 rejection from the new validator was technically captured but never shown — the modal vanished too quickly. Now (a) errors render inline as red text under the file row instead of as a hover-only `title` tooltip, and (b) the modal stays open if any file ended with status='error', so the user can read the backend's actual remediation message before clicking Close. The bulk archive `UploadModal.tsx` was already showing inline errors and not auto-closing — that one didn't need the fix. **Tests**: 7 new integration tests in `TestPrintFileUploadValidation` cover: raw `.gcode` rejection at the library route (asserts the error message names the remedy), non-zip `.3mf` rejection, non-zip `.gcode.3mf` rejection (compound-extension code path), happy-path valid `.gcode.3mf` accepted, STL / non-print extensions still bypass, `POST /archives/upload` non-zip rejection, `POST /archives/upload-bulk` per-file error collection with mixed good/bad files in one request. Plus one fixture update in `test_external_folders_api.py` — `test_upload_persists_correct_db_shape` was uploading `model.3mf` with placeholder bytes `b"x"` to exercise the DB-shape path; updated to use a minimal real zip so the new validator doesn't block the unrelated test. 4968 backend tests green, 41 FileUploadModal frontend tests green, ruff + frontend build clean.

### Added
- **Inventory: Storage Location filter chip (#1400, reported by @pgladel)** — Reporter manages a lot of physical filament storage locations and wanted a quick way to narrow the inventory list to "what's in shelf A" / "what's in drawer 1" without typing a search query each time. Inventory page grows a new filter chip alongside the existing Material / Brand / Category / Spool Name dropdowns. Distinct storage-location values are pulled from the spool list and rendered as options; selecting one filters the table to spools assigned to that location. An additional **No location set** entry appears when at least one spool has an empty `storage_location`, so users can find unfiled spools the same way `categoryNone` works for unfiled categories. The chip self-hides when no spool has a storage location set (avoids noise on fresh installs). Pattern is identical to the existing Category chip from #729 — clear-all-filters and `hasActiveFilters` both include the new state. **Whitespace normalisation:** distinct-value extraction and filter comparison both `.trim()` the field so a spool whose location was saved as `"Shelf A "` doesn't render as a separate dropdown option from `"Shelf A"`. **i18n:** reuses the existing `inventory.storageLocation` label (already shipped for the spool-edit field — no duplication); adds a new `inventory.storageLocationNone` key, translated to all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). The "Extended Solution" from the issue (dashboard widget showing locations) is not in this change — open to revisiting if there's appetite. Parity check holds at 4818 leaves per locale. 24 InventoryPage tests in the existing suite still pass.

- **Smart plugs: auto-off after AMS drying completes (#1349, reported by @Kyobinoyo)** — Reporter asked for the equivalent of the existing print-finish auto-off, but triggered when an AMS drying cycle ends — so the smart plug that powers the printer + AMS combo cuts power once humidity has been driven out, without the user babysitting it. Shipped as a simple per-plug pair of fields that mirrors the existing print-finish auto-off shape. Per-AMS plug routing (separate plug for the AMS only, per-AMS targeting on dual-AMS printers) was scoped out for now — Bambuddy's plug model is plug→printer, not plug→AMS, so the trigger fires whenever any AMS attached to the linked printer finishes a dry cycle. **Two new SmartPlug columns** with a same-migration block in `database.py` (SQLite uses `BOOLEAN DEFAULT 0` / `INTEGER DEFAULT 10`; Postgres branches to `DEFAULT false` / `IF NOT EXISTS`): `auto_off_after_drying BOOLEAN` (defaults False so nobody opts in by accident); `off_delay_after_drying_minutes INTEGER` (defaults 10 — separate from the print-finish delay because the AMS chamber is hot post-cycle and users often want longer cooldown than the print-finish default of 5). **Trigger** is observed at the MQTT layer, not the scheduler — `BambuMQTTClient` now keeps a per-AMS `_previous_dry_times: dict[int, int]` and, every time `_handle_ams_data` finalises the merged AMS list, walks each unit looking for the `dry_time > 0 → 0` falling edge. When it fires, the new `on_drying_complete(ams_id)` callback runs, plumbed through `PrinterManager.set_drying_complete_callback` exactly the way `on_print_start` / `on_print_complete` already are. The seed-from-zero false positive (first MQTT push reports `dry_time=0` and the previous would otherwise read as 0→0) is guarded by the explicit `previous > 0` check, and the per-AMS state means dual-AMS printers can finish drying on AMS 0 and AMS 1 independently without the second one missing the edge. Observing the falling edge at the MQTT layer (rather than in `print_scheduler._sync_drying_state`) is deliberate: the scheduler's `_drying_in_progress` dict only tracks auto-drying initiated by the scheduler itself, so manually-triggered drying from the printer card would not fire there. The new path catches queue-triggered, ambient, AND manual drying identically because it observes firmware-reported state, not our own intent. **Manager hook** in `SmartPlugManager.on_drying_complete(printer_id, db)` mirrors `on_print_complete` but reads the drying-specific toggle, calls `_schedule_delayed_off` with `off_delay_after_drying_minutes` (always time-based — temperature-cooldown is meaningful for the printer hotend, not the AMS chamber, and Bambuddy doesn't track AMS chamber temperature). The HA-script guard from the print-finish path is preserved (scripts can be triggered but not turned off, so they're skipped). **Frontend** adds a single toggle + delay input on the Smart Plug card next to the existing "Auto Off" section: "Auto Off After Drying" and "Drying delay (minutes)". No changes to the Add Smart Plug modal beyond what the new fields require. **Backend tests** in `test_smart_plug_manager.py` cover the new shape: drying auto-off schedules with the correct per-plug delay; the toggle being off is a no-op even when `auto_off` (print-finish) is on; the master `enabled` flag still gates; HA script entities are skipped; printer with no linked plugs is a silent no-op. `test_bambu_mqtt.py` gets a new `TestDryingCompleteCallback` class covering the falling-edge firing once, the seed-from-zero non-fire guard, repeated zero-pushes after the edge not refiring, per-AMS independent tracking on dual-AMS units, and the "new cycle after completion refires" case (covers the user starting a second dry from the printer card). 4961 backend tests green; SQLite + Postgres 16 migration verified idempotent. **i18n**: 3 new keys (`autoOffAfterDrying`, `autoOffAfterDryingDescription`, `delayAfterDryingMinutes`) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). Parity check holds at 4817 leaves per locale.

### Changed
- **Bulk and scheduled archive purge now honour the soft / hard delete choice that single-archive delete already exposes (#1390 follow-up)** — Reporter IndividualGhost1905 followed up after the #1378 / #1343 backfill fix landed and pointed out the next inconsistency: the per-archive delete dialog has had a "Also remove from Quick Stats" checkbox since #1343, but the bulk "Purge Old" button and the scheduled daily auto-purge sweeper both ignored that choice and hard-deleted unconditionally. The "Purge Old" path called `archive_purge_service.purge_older_than` which routed through `ArchiveService.delete_archive` directly — dropped the archive row, the linked PrintLogEntry rows got `ON DELETE SET NULL` so they survived with `archive_id=NULL`, Quick Stats kept the filament / cost / energy contribution from the orphaned log rows but the archive-list-iterating widgets (Filament Trends / Printer Stats / By Material / Color Distribution) lost the contribution and Time Accuracy lost the join target. Visibly inconsistent, and "automatically deleted from statistics without any warning" was a fair characterisation of the half that did drop. Fix is to thread the same `purge_stats` parameter through every surface, defaulting to soft-delete (matches the single-archive default — files off disk, archive row hidden via `deleted_at`, Quick Stats fully preserved, all archive-list widgets keep showing the row). Three surfaces affected: (1) `POST /archives/purge` accepts `purge_stats` in the body, defaults False (soft); the response now echoes which mode ran. (2) `GET /archives/purge/preview` accepts the same flag as a query param so the count matches what a real purge would touch — soft mode excludes already-soft-deleted rows, hard mode counts them as eligible-for-promotion. (3) The auto-purge `archive_auto_purge_stats` setting (default False) controls whether the daily sweeper runs in soft or hard mode; the existing `_maybe_run_auto_purge` reads it on every tick. `ArchivePurgeRequest` / `ArchivePurgeSettings` schemas extended, `archive_purge_service.purge_older_than` and `preview_purge` take `purge_stats=False` kwarg, the existing single-row delete tests pass unchanged. Frontend: "Purge old archives" modal grew a checkbox below the preview ("Also remove from statistics" with a hint explaining the difference), and the Settings → Archives auto-purge card grew the matching toggle (disabled when auto-purge itself is off). Copy in the modal rewritten across all 8 locales to reflect that the default no longer "permanently removes from the database" but instead hides + removes files while keeping Quick Stats intact. **Behaviour change for existing auto-purge users:** the sweeper used to hard-delete by default and now soft-deletes by default. After the upgrade, existing auto-purge users will start *preserving* more data in Quick Stats rather than losing it — the safer direction of the two, but call it out. Users who want the old hard-delete behaviour can tick the new toggle once. 4 new integration tests in `test_archive_purge_api.py` pin the new contract: manual purge soft-deletes by default, manual purge hard-deletes when `purge_stats=true` body flag is set, auto-purge soft-deletes by default, auto-purge hard-deletes when the settings opt-in. Existing throttle/disabled tests still pass. 11 tests total in the file, all green; 4951 in the full backend suite. i18n parity check clean across all 8 locales.
- **Cloud login: corrected the access-token hint to reflect that Bambu Lab no longer surfaces the token in any UI, and called out the China-region constraint explicitly (#1396)** — Reporter wintsa123 filed that China-region users can't log into Bambuddy. The code path itself is fine: PR #1013 (April) already added the China-region selector to the login form and routes token validation to `api.bambulab.cn`. The actual gap was documentation. The old in-app `accessTokenHint` said "Paste your Bambu Lab access token (from Bambu Studio)" — but Bambu Studio never exposed the token in any UI, and the profile page on `bambulab.com` that used to show it is gone. For China-region accounts the email/password flow is fundamentally unusable because those accounts are bound to phone numbers, not email — token login is the only path, and the hint didn't say so. Updated `accessTokenHint` in all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW) to state that China accounts must use this path and point at the wiki for the MakerWorld-cookie retrieval procedure. Wiki page `features/cloud-profiles.md` also rewritten under "Access Token Login": adds a "Region: China must use token login" note, replaces the dead "from Bambu Studio" guidance with the working MakerWorld-cookie method (browser DevTools → Application → Cookies → `token`), keeps the Python-script alternative for global-region accounts, and flags that the cookie value is sensitive. No backend changes — the token-validation endpoint accepts both `global` and `china` regions and routes to the right API host already.

### Fixed
- **Virtual Printer (queue / immediate / review modes): AMS data flickered or disappeared in BambuStudio between pushalls on P1S/A1 targets (#1387)** — Reporter vmhomelab ran a Print Queue VP against a P1S, opened BambuStudio, and saw the External Spool only — no AMS. Toggling Auto-Dispatch (which triggers a VP restart) made AMS briefly appear, then it reverted to defaults. Proxy Mode worked fine. The earlier #1371 sticky-keys fix only handled one of two Bambu firmware incremental-push shapes: it preserved cached AMS when the incoming push *omitted* the `ams` key entirely (H2D's common incremental shape). The reporter's P1S firmware (01.09.01.00) instead sends incrementals with the `ams` key present but the inner `ams.ams` array stripped — `{ams_status: 1, humidity: 2}` instead of `{ams: [...], ams_status: 1}`. To the previous sticky-keys check that read as "key present, leave new state alone," so the bridge cache got overwritten with the stripped blob; the slicer's next 1 Hz read saw `ams` with no unit list and fell back to the "no AMS" default render. Toggling Auto-Dispatch restarted the VP and got a fresh pushall in; the next P1S incremental stripped it again. (H2D rarely hits this — its incrementals typically don't carry `ams` at all, so #1371 alone was enough there. The reporter's same-VP-architecture pinging both an H2D and a P1S would observe the H2D works while the P1S doesn't, which is exactly the split that surfaced this.) Fix is a deep-merge applied to the `ams` key inside the bridge cache, mirroring the structure Bambuddy itself already does in `bambu_mqtt.py::_handle_ams_data` (which is why Bambuddy's own AMS display stays coherent on the same firmware): scalar fields like `ams_status` and `humidity` take the new value, but the `ams.ams` array is merged unit-by-unit on `id`, each unit's `tray` array is merged tray-by-tray on `id`, and units / trays the incremental doesn't mention survive intact from the cached full state. A tray-targeted incremental during a print like `{ams: [{id: 0, tray: [{id: 0, state: 11}]}]}` now updates that one tray's state without nuking the other three trays' tray_type/tray_color. Helper added as `_merge_ams_dict` in `backend/app/services/virtual_printer/mqtt_bridge.py` next to `_ip_to_uint32_le`, called from the existing sticky-keys block. Three new regression tests under `TestPushStatusCache` in `backend/tests/unit/test_vp_mqtt_bridge.py` cover the status-only partial (the reporter's exact reproduction), the multi-AMS unit-level merge, and the multi-tray merge. The existing `test_incoming_ams_update_replaces_cached_ams` still passes — fresh full updates still take effect, the merge only protects the cache from stripped incrementals. 32 tests total in that file, all green. Verified the cross-subnet topology from the report (printer / Bambuddy / slicer each on a different /24) is incidental: the symptom is the same regardless of subnet once the partial-shape arrives; the latency just makes the "empty cache when slicer first connects" race more visible. ProxyMode is unaffected because Proxy is raw byte-forwarding rather than a cached-as-base mirror — it never had this class of bug.
- **Quick Stats showed Filament Cost = 0 and empty Time Accuracy on pre-upgrade data after the 0.2.4.1 stats rewrite (#1390)** — Reporter IndividualGhost1905 upgraded to 0.2.4.1 (which shipped the per-event aggregation rewrite from #1378) and saw the Stats page split between consistent values (Total Prints / Print Time / Filament Used / Energy / Success Rate matched the archive list) and zero-or-empty ones (Filament Cost, Time Accuracy). Inconsistency was a migration gap: #1378 added six columns to `print_log_entries` — `archive_id`, `cost`, `energy_kwh`, `energy_cost`, `failure_reason`, `created_by_id` — but **didn't backfill any of them**. So every pre-upgrade log entry kept NULL on all six. The new Quick Stats query sums `PrintLogEntry.cost` (gets 0 for legacy data); the time-accuracy query joins `PrintArchive ON archive_id` (drops every legacy run from the average). Counts and per-row fields that already existed pre-#1378 (`status`, `duration_seconds`, `filament_used_grams`) kept working — which is why some panels looked right and others didn't. Fix is a two-step backfill in `run_migrations` next to the existing column-add block (DML, runs inside `begin_nested()` not `_safe_execute` since the latter is documented "DDL only"): step 1 links each orphan log entry to its archive via `print_name + printer_id` (highest archive `id` wins on tiebreak — newest matches the overwrite-then-stop shape that pre-#1378 reprints left behind); step 2 copies `archive.cost / energy_kwh / energy_cost` onto the latest matching log entry per archive, **but only for archives where no log entry yet carries a cost**. That second clause is the idempotency anchor and also the double-count guard for users running this migration after #1378 has already written cost-bearing rows for new runs — those archives are left untouched. Earlier reprints stay NULL, matching the "first/latest writes, rest stay NULL" convention #1378 introduced. Sum across the legacy reprint chain reproduces sum-of-archive-cost exactly, so the Quick Stats Filament Cost column matches the pre-upgrade total instead of dropping to zero. SQL is plain ANSI — correlated UPDATE with `LIMIT 1` in the SET subquery, `WHERE id IN (SELECT MAX(id) ... GROUP BY archive_id HAVING SUM(CASE WHEN cost IS NOT NULL THEN 1 ELSE 0 END) = 0)` — verified end-to-end on both SQLite (4 unit tests in `test_print_log_backfill_migration.py`) and `postgres:16-alpine + asyncpg` (live container reproduction). For the other widgets the reporter listed (Printer Stats, Filament Trends, By Material, Success by Material, Color Distribution) — those still iterate the archives list on the frontend rather than calling /stats, so they read consistent pre-upgrade data and aren't part of this fix; the inconsistency the reporter saw between Quick Stats and those widgets resolves itself once the backfill brings Quick Stats in line.
- **Spoolman: spool "Color Name" edits silently never saved — Bambuddy was writing to a field Spoolman doesn't have (#1357)** — Reporter pgladel edited a spool's Color Name in Spoolman mode, hit Save, and saw the value snap back to the subtype on the next read. Martin shipped #1319 in May to handle "form round-trips the synth value back as if it were user input" — that fix's read/form-prefill half was correct (the `color_name_is_synthesized` flag, the blank-on-synth form init), but the **write half assumed Spoolman has a `color_name` field on Filament**. It doesn't. Verified against the live `FilamentUpdateParameters` schema on Spoolman 0.23.1: `name`, `vendor_id`, `material`, `price`, `density`, `diameter`, `weight`, `spool_weight`, `article_number`, `comment`, `settings_extruder_temp`, `settings_bed_temp`, `color_hex`, `multi_color_hexes`, `multi_color_direction`, `external_id`, `extra` — that's the lot. No `color_name`. Spoolman's PATCH happily returns 200 for `{"color_name": "Red"}` and just **silently discards the unknown key**. So `find_or_create_filament` was either patching a void or creating filament after filament with the same field-that-doesn't-stick (which is what produced the reporter's "BB also created a bunch of new filaments" trail of duplicates on each save attempt). The fix takes the same route as the existing BambuStudio slicer-preset storage: persist color_name on `spool.extra.bambu_color_name` as a JSON-encoded string, register the extra field via `ensure_extra_field` before write (Spoolman 400s on unknown extra keys), and read it back in `_map_spoolman_spool` with priority `spool.extra.bambu_color_name → filament.color_name (forward-compat for any future Spoolman release that adds it) → subtype synth`. Also dropped the now-dead `color_name` passing through `find_or_create_filament` and `create_filament` — Spoolman would discard it anyway and keeping the dead pipe risked the same confusion the next time someone reads this code. The previous "match by name then patch color_name" loop is gone; what survives is the name-match resilience added earlier this turn so an AMS-sync-created filament named `"Glow"` still matches the user-driven edit's composed `"PLA Glow"`, which prevents the duplicate-filament trail. The frontend form's `color_name_is_synthesized` handling is unchanged — that part already worked. Tests rewritten across the three affected suites (`test_spoolman_inventory_methods.py`, `test_spoolman_inventory_helpers.py`, `test_spoolman_inventory_api.py`) to pin the new contract: filament patch never carries `color_name`, route writes to `bambu_color_name` extra, read prefers extra over filament-field over synth. Verified end-to-end against the live Spoolman instance at the reporter's setup (PATCH /filament with color_name → field absent from response; PATCH /spool with extra.bambu_color_name → field present in response).
- **Add Smart Plug (HA mode) — search dropdown let users pick entities the schema would reject, surfacing as a cryptic regex error on Save (#1388)** — Reporter MartinNYHC opened the Add Smart Plug dialog, typed a search prefix matching a multi-entity HA device (a Shelly-style outlet exposing one `switch.*` and several `sensor.*` / `binary_sensor.*` siblings under the same friendly-name prefix), clicked one of the entities, filled in the optional power/energy sensors, and clicked Save. The backend returned 422 with the raw Pydantic message `String should match pattern '^(switch|light|input_boolean|script)\.[a-z0-9_]+$'`. After the dropdown closed and the search cleared, the entity-list refetch (with no search param) returned the default-domain-filtered list — which didn't include the user's pick — so `selectedEntity = haEntities.find(...)` was undefined, the field rendered as visually empty (placeholder shown), but `haEntityId` still held the bad value the user had selected. Root cause was at `backend/app/services/homeassistant.py::list_entities`: when a search query was present, the function bypassed the domain filter entirely and returned matches across every HA domain — including ones the `SmartPlugBase.ha_entity_id` regex at `backend/app/schemas/smart_plug.py:17` could never accept. Offering a clickable choice the user can't save is broken UX; the fact that the error message then said `switch|light|input_boolean|script` made it look like a schema problem rather than a search-permissiveness problem. Fix: the allowed-domains filter (`{"switch", "light", "input_boolean", "script"}`, kept in sync with the schema regex) now always runs, and search composes on top of it as an additional substring match against `entity_id` or `friendly_name`. Whitespace-only search strings are treated as no search. Verified the smart-plug code path is unchanged between 0.2.4 and 0.2.4.1 — this bug was latent since the script-domain commit in February 2026 and was only noticed now because the reporter hadn't reopened the modal in months. 5 new regression tests in `backend/tests/unit/services/test_homeassistant_list_entities.py` cover the no-search baseline, the search-still-domain-filters case (the actual #1388 reproduction), the entity_id-or-friendly_name substring match, case-insensitivity, and the whitespace-only edge case.
- **H2S with no AMS could not start a print — firmware rejected the dispatch with `07FF_8012` "Failed to get AMS mapping table" (#1386)** — Reporter krootstijn (H2S + no AMS) clicked Print and got an immediate firmware error. Two stacked misclassifications had quietly added H2S to the dual-nozzle code paths over time. The first was in `start_print_job` at `backend/app/services/bambu_mqtt.py:3168` — the `is_h2d` flag was set true for `("H2D", "H2D PRO", "H2DPRO", "H2C", "H2S", "X2D")`. That single flag controlled both the firmware bool→int format (legitimately needed for the whole H-family) *and* the external-spool routing branch (`ext_ams_id = tray_id if is_h2d else 255`) which is only correct for actual dual-nozzle printers. With no AMS, the external-spool sentinel is `254`; the dual-nozzle branch wrote `ams_id=254` into `ams_mapping2` instead of the canonical `255`. The exact failure shape (`07FF_8012`) is even called out in the comment six lines above the bad line — H2S was getting routed straight into the path the comment warned against. The second misclassification was the use_ams=False fallback at `bambu_mqtt.py:3213` (`if ams_mapping and use_ams and not is_h2d`) — meant to skip the safety drop on dual-nozzle printers where `use_ams` controls nozzle routing — also skipped H2S, so the firmware never got a chance to fall back to external-spool mode. A third site at `bambu_mqtt.py:3987` (and its sibling at `backend/app/api/routes/kprofiles.py:119`) classified dual-nozzle by serial prefix `("094", "20P9", "31B8B")`, which is wrong because H2S shares prefix `094` with H2D. Fix splits the conflated flag into two: `is_h_family` (firmware-format gate, includes H2S) and `is_dual_nozzle` (routing/use_ams gate, excludes H2S; prefers the runtime `_is_dual_nozzle` flag set from `device.extruder.info` and falls back to model name for the brief window right after connect). The K-profile delete and the edit route now use the same two-source check instead of the serial prefix. Empirically verified across 9+ stored H2S support bundles (`nozzle_count: 1` in every one) and the reporter's bug log (`07FF_8012` immediately after dispatch). Four new regression tests: `test_h2s_single_external_spool_uses_main_id`, `test_h2s_no_ams_forces_use_ams_false`, `test_h2s_keeps_integer_format_for_calibration_fields`, plus a new `test_h2s_uses_single_nozzle_format` in the K-profile suite. The K-profile detection tests were also updated to set both model name and runtime flag rather than relying on serial prefix, since the source-of-truth has shifted.

## [0.2.4.1] - 2026-05-16

### Changed
- **Support bundle audited for new features — adds OIDC, 2FA, API keys, library/inventory/queue/maintenance totals, slicer-API reachability, GitHub backup status, per-printer Obico flag; also redacts two settings that were leaking and fixes a reachability-check architecture bug** — The `support-info.json` block in support bundles auto-includes the `settings` table (with sensitive-key redaction), so settings-stored features like LDAP, Obico globals, integrated slicing URLs, Tailscale, and queue-drying already flowed through. What was missing was anything stored in **dedicated tables**, which had grown substantially without the bundle being updated. Triaging the recent OIDC / 2FA / group bugs (#1292, #1297) and the X1C slicer investigation involved repeatedly asking reporters for information that should have been in the bundle. New blocks added to `_collect_support_info` in `backend/app/api/routes/support.py`: **`auth`** — OIDC providers (cleartext `name`, `is_enabled`, `scopes`, `email_claim`, `require_email_verified`, `auto_create_users`, `auto_link_existing_accounts`, `has_default_group`, `has_icon`, `linked_user_count`; `client_id`/`client_secret`/`issuer_url` stay out of the bundle), 2FA counts (`users_with_totp`, `email_otp_codes_pending`), API key counts (`total` / `enabled` / `expired`), long-lived token counts (`total` / `active`), group counts (`system` / `custom`). **`library`** — `library_files_total`, `library_files_in_trash`, `library_folders_total`, `external_folders_total`, `external_links_total`, `makerworld_imports_total`. **`inventory`** — `spools_internal`, `k_profiles_internal`, `k_profiles_spoolman`. **`queue`** — `pending_total`, `manual_start_pending`, `oldest_pending_age_seconds` (catches items stuck because their target printer is offline or filament doesn't match). **`maintenance`** — `items_total`, `items_enabled`. **`integrations.github_backup`** — `configs_total`, `providers_used` dict (github/gitea/forgejo/gitlab), `schedule_enabled_count`, `last_failure_count`. **`integrations.slicer_api`** — `enabled`, `preferred`, `bambu_studio_url_set`, `orcaslicer_url_set`, plus an actual 2-second HTTP reachability ping (`bambu_studio_reachable`, `orcaslicer_reachable`) to differentiate "URL empty" from "URL misconfigured" from "service down". **Per-printer `obico_enabled`** flag added to each entry in `printers[]`, parsed from `obico_enabled_printers` setting via a new `_parse_obico_enabled_printers` helper that tolerates legacy comma-separated formats. **Plus three smaller but important fixes caught while testing the bundle against a real instance**: (1) **`mqtt_broker` value was leaking** — the keyword-substring redaction filter at `support.py:850` had no entry that matched the `mqtt_broker` setting name, so the broker IP (e.g. `192.168.255.16`) was appearing in cleartext. Added `broker` to `sensitive_keys`. (2) **`virtual_printer_tailscale_auth_key` was leaking** — same reason, no keyword in the filter matched `_auth_key`. Added `auth_key` to the keyword set, AND added a value-prefix safety net (`tskey-`) so any FUTURE Tailscale setting with an unexpected name still auto-redacts when its value starts with the Tailscale auth-key prefix. (3) **Slicer-API reachability check was always returning `null` / `false` even when the slicer was up** — two root causes stacked. First, the old code passed `info["settings"]` (already redacted) into `_collect_slicer_api_info`, so when `bambu_studio_api_url` had been redacted to `"[REDACTED]"`, the httpx call hit that literal string and crashed; when the setting was empty, the URL came through as `""` and the function returned `None`. Second — caught on the next round of testing — even after switching to read directly from `Settings.value`, the check only looked at the DB row, but the real slicer routes (`archives.py:3174-3180`, `library.py`) resolve the URL with a three-level precedence: DB setting → `app_settings.bambu_studio_api_url` (which reads the `BAMBU_STUDIO_API_URL` env var) → built-in default `http://localhost:3001`. Most installations run the sidecar on the default port or via env var, so the DB-only check returned `null` even when the slicer was up and reachable. The collector now mirrors the route's exact resolution path. The block now also reports `bambu_studio_url_set_in_db: bool` and `bambu_studio_url_source: "db" | "env_or_default" | "unset"` so triage can see WHICH layer supplied the URL — separates "user explicitly configured it" from "they're using the default port" without leaking the URL itself. Two regression tests pin both layers: `test_reachability_uses_unredacted_url` (no `"[REDACTED]"` ever reaches `_check_url_reachable`) and `test_env_var_fallback_url_pinged_when_db_setting_empty` (DB empty + env-var-set URL is actually pinged and reported reachable). All new collectors are wrapped in `try/except` so a single failure on one block can't blank the rest of the bundle. OIDC provider names are passed in cleartext deliberately — they're login-button labels (`PocketID`, `Authentik`, `Google`, etc.), not secrets, and provider-specific behavior (Azure handles claims differently from Authentik) is exactly the kind of detail that makes SSO bugs triagable in one round-trip instead of three. 13 new unit tests in `backend/tests/unit/test_support_helpers.py` cover the obico-parser edge cases, slicer-API reachability with mocked httpx (including the "404 = reachable" decision, the un-redacted-URL regression, AND the env-var-fallback regression), auth-info OIDC-cleartext-but-no-secrets contract, the GitHub-backup provider/failure aggregation, and the new `mqtt_broker` / `virtual_printer_tailscale_auth_key` / value-prefix-based redactions.

- **Page headers unified across the app: consistent icon size, placement, and subtitle styling** ([PR #1272](https://github.com/maziggy/bambuddy/pull/1272) by @EdwardChamberlain, continuation of #1060 / #1203) — Nine pages (Archives, FileManager, Inventory, Maintenance, MakerWorld, Profiles, Projects, Settings, Stats) now share one header pattern: `w-7 h-7 bambu-green icon` next to a `text-2xl font-bold` title with a `text-bambu-gray mt-1` subtitle underneath, matching the look that landed earlier on Print Queue and Printers. FileManager and Projects dropped their rounded `bg-bambu-green/10 rounded-xl p-2.5` icon tile in favor of the plain icon to match the rest. The sidebar's "Queue" nav item is renamed to "Print Queue" (and its icon switched from `Calendar` to `ListOrdered`) to match the page header it leads to. The Stats page title is renamed `Dashboard → Statistics` to match the sidebar nav label that's been pointing at it (the page never was the printer dashboard — Printers is — and the mismatch confused new users; closes a small but recurring source of "where's the dashboard?" support questions). All renames flow through every locale: en/de/fr/it/ja/pt-BR/zh-CN/zh-TW updated for `nav.queue`, `stats.title`, plus a new `inventory.subtitle` key ("Manage your spools" + translations) used by the inventory header. Bonus on top of the stated scope: `inventory.toolbar.{filters, view, actions}` were untranslated English strings in fr/it/ja/pt-BR/zh-CN/zh-TW — Edward translated them properly in the same pass. `StatsPage.test.tsx` updated to assert the new "Statistics" title. Build clean, all 35 page tests still pass, i18n parity holds at 4753 leaves across all 8 locales. Maintenance page subtitle keeps its red / amber / green severity color on the "X items due · Y warnings · all up to date" line — the colors carry actual at-a-glance status information, not just visual weight.

- **Bambuddy now identifies honestly as itself on every outbound request to Bambu Lab / MakerWorld / Bambu Wiki** — proactive alignment with Bambu Lab's [2026-05-12 statement on cloud access](https://blog.bambulab.com/setting-the-record-straight-on-cloud-access-and-community/), which draws a clear line between modifying AGPL code (allowed) and "impersonating official clients in communication with our cloud infrastructure" (not allowed). Bambuddy was already on the right side of that line on the main authenticated cloud path (`User-Agent: Bambuddy/1.0` in [`bambu_cloud.py:_get_headers`](backend/app/services/bambu_cloud.py)), but three secondary call sites were sending browser User-Agents — originally added under the assumption Cloudflare's WAF would block non-browser identification. Tested on 2026-05-12 with `curl -H "User-Agent: Bambuddy/1.0"` against all three: `https://bambulab.com/api/sign-in/tfa` returned HTTP 400 with the expected application-level `{"code":5,"error":"Login failed"}` JSON (no Cloudflare interstitial), `https://api.bambulab.com/v1/iot-service/api/slicer/setting` returned HTTP 200 with the full 576 KB settings response, `https://makerworld.com/api/v1/design-service/*` returned the same response shape as a Firefox UA, and `https://wiki.bambulab.com/*` served identical HTML to a Chrome UA. The browser-impersonation was unnecessary. All four call sites now send `Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)` consistently — the URL in parens makes the source unambiguous so Bambu can distinguish our traffic from impersonators if they ever audit it. Files: [`bambu_cloud.py`](backend/app/services/bambu_cloud.py) (TOTP/TFA path no longer spoofs Chrome UA + Origin + Referer + Accept-Language headers — Origin/Referer were spoofing `bambulab.com` origin, which the new comment block specifically calls out as removed), [`makerworld.py`](backend/app/services/makerworld.py) (Firefox UA replaced; the Referer header is kept because MakerWorld's CSRF / origin-check middleware uses it on some endpoints, which is functional, not identity-faking), [`firmware_check.py`](backend/app/services/firmware_check.py) (Chrome UA on the public wiki scraper replaced — wiki has no special handling for our UA). Separately: the [`/v1/iot-service/api/slicer/setting`](backend/app/services/bambu_cloud.py) endpoint requires a `version` query parameter in Bambu Studio's XX.YY.ZZ.WW format (the API returns HTTP 400 "field 'version' is not set" without it, and HTTP 422 "Invalid input parameters" for non-matching formats like `bambuddy-1.0`), but Bambu's server accepts ANY value within that format — verified the same 576 KB response with `version=99.99.99.99`. The previous default `"02.04.00.70"` is an actual Bambu Studio release version (2.4.0.70). The default is now `"1.0.0.0"` (held in a new `_SLICER_API_VERSION` module constant in [`bambu_cloud.py`](backend/app/services/bambu_cloud.py) and re-exported into [`routes/cloud.py`](backend/app/api/routes/cloud.py) so the two route defaults stay in sync), which satisfies the format requirement without claiming to be a specific Bambu Studio build. Unchanged on purpose: `version="2.0.0.0"` parameters in `create_setting` / `update_setting` payloads are the **preset's** format version (extracted from `current.get("version", "2.0.0.0")` for updates, line 443) — they describe the preset schema, not the client, and stay as-is. Two regression tests rewritten to lock in the new behavior: `test_verify_totp_uses_honest_bambuddy_user_agent` (was `test_verify_totp_includes_browser_headers` — asserts UA starts with `Bambuddy/`, asserts `Mozilla`/`Chrome`/`Origin`/`Referer` are not present) and `test_sends_honest_bambuddy_user_agent` (was `test_sends_browser_like_headers` — same shape, plus continues to assert the deprecated `x-bbl-*` Bambu-app identification headers are still gone). All 4598 backend tests pass.

- **Spoolman weight tracking now uses per-print grams for all spools, matching the internal Filament Inventory** ([#1119](https://github.com/maziggy/bambuddy/issues/1119), reported by @Moskito99) — Spoolman previously had two mutually-exclusive weight paths: AMS remain%×tray_weight auto-sync (default; only worked for Bambu Lab spools with valid RFID tray_weight) and per-print 3MF-grams tracking (only enabled when "Disable AMS Weight Sync" was toggled on). Non-BL spools without RFID fell through both paths — AMS auto-sync had no tray_weight to multiply, and the inventory_remaining fallback was wiped because activating Spoolman deletes the internal `spool_assignment` table — so Spoolman never saw a weight update for them. The internal Filament Inventory has no such gap: it always uses per-print 3MF grams as the primary path with AMS-remain% delta as fallback, and it works for every spool type. Spoolman now does the same: per-print tracking runs whenever Spoolman is enabled and is the only writer of `remaining_weight`. AMS auto-sync continues to maintain spool metadata and slot assignments but no longer touches weight (eliminating the double-count that would otherwise occur for BL spools with both paths active). `store_print_data` ([`spoolman_tracking.py:159`](backend/app/services/spoolman_tracking.py)) had its `disable_weight_sync` early-return removed; the three `sync_ams_tray` callsites (`main.py:1450` auto-sync, `spoolman.py:318` per-printer manual, `spoolman.py:517` sync-all) now hard-code `disable_weight_sync=True`. The `spoolman_disable_weight_sync` setting is now deprecated and a no-op — kept in the DB/UI for backwards compat. Behavioral consequence for existing users on the default flag (False): live AMS-based remaining_weight updates between prints stop happening; weight updates now arrive once per print completion with 3MF gram precision. Regression test in `test_spoolman_tracking.py::test_stores_tracking_when_disable_weight_sync_is_false` proves the early-return is gone.

### Added
- **Manual LDAP user provisioning from the UI** ([#1298](https://github.com/maziggy/bambuddy/issues/1298), reported by @Fuechslein) — Until now the only way to onboard an LDAP user was to leave `Auto-provision` on and have them log in once, because the create-user form had no LDAP awareness — admins who wanted to disable auto-provision had to hand-edit the database to create the row. The user-create modal now grows a `Local / LDAP` tab toggle (visible only when LDAP is enabled in settings, so non-LDAP installs see no UI change). The LDAP tab is a directory search: type ≥2 characters and the new `GET /auth/ldap/search` endpoint uses the service-account bind to query the directory with a fixed OR filter across `sAMAccountName`, `uid`, `mail`, `displayName`, and `cn` (covering both Active Directory and OpenLDAP layouts; user input is RFC-4515 escaped so a typed `*` doesn't enumerate the whole tree). Each result is annotated with `already_provisioned` so usernames that already exist as BamBuddy users render dimmed and disabled. Picking a result and clicking **Provision user** hits `POST /auth/ldap/provision`, which re-resolves the username via the service bind (rather than trusting the client payload) and calls the same `_provision_ldap_user` helper the auto-provision login path uses — so group mapping, default-group fallback, and email sync behave identically regardless of which path created the user. Distinct error responses cover the failure modes (`400` LDAP disabled / query too short, `404` directory miss, `409` username already exists locally vs. already-provisioned LDAP user, `503` directory unreachable with the underlying ldap3 exception class + message in the detail field so the operator can diagnose without reading backend logs). Backend refactor extracts `_open_service_connection` + `_extract_user_info` helpers in [`backend/app/services/ldap_service.py`](backend/app/services/ldap_service.py) so the new `lookup_ldap_user` and existing `authenticate_ldap_user` share the bind + attribute-extraction paths (POSIX `memberUid` + primary `gidNumber` + case-insensitive DN dedupe stay in one place). **Two ldap3 schema-check workarounds for OpenLDAP installs** (caught in user testing against an OpenLDAP directory): (1) the directory-search connection is opened with `check_names=False` because ldap3's client-side filter validation rejects the AD-only `sAMAccountName`/`displayName` names in the cross-schema OR filter before any packet is sent; (2) the search requests `attributes=["*"]` (all user attributes) rather than the explicit AD-flavoured name list, because ldap3's `build_attribute_selection` validates each named attribute against the server schema *independently* of `check_names` and only the `*` wildcard is in its hard-coded `ATTRIBUTES_EXCLUDED_FROM_CHECK` exclusion list — so a list like `["sAMAccountName", "uid", ...]` still throws `LDAPAttributeError` on OpenLDAP. The login/lookup paths (`authenticate_ldap_user`, `lookup_ldap_user`) keep `check_names=True` so typos in the configured `user_filter` setting still fail loudly. New shared frontend component `<LdapUserPicker>` in [`frontend/src/components/LdapUserPicker.tsx`](frontend/src/components/LdapUserPicker.tsx) handles the debounced search (300 ms, min 2 chars), result list, selection, and provision mutation; it's rendered from **all four** create-user modal paths — basic + advanced-auth in [`UsersPage.tsx`](frontend/src/pages/UsersPage.tsx), basic + advanced-auth in [`SettingsPage.tsx`](frontend/src/pages/SettingsPage.tsx) (the latter being the "Add User" inside Settings → Authentication, which uses a separate modal flow from the dedicated Users page) — and the shared [`CreateUserAdvancedAuthModal`](frontend/src/components/CreateUserAdvancedAuthModal.tsx) gains a `ldapEnabled` + `onLdapProvisioned` prop pair so both pages drive the same component. i18n: 14 new keys under `users.modal.ldap*` + `users.modal.{localTab,ldapTab,tabsAriaLabel}` + 1 toast key in `frontend/src/i18n/locales/en.ts` (other 7 locales fall back to English per project convention). The wiki at [`features/authentication.md`](https://wiki.bambuddy.cool/features/authentication/) was also corrected — the prior "When disabled, an admin must pre-create the user in BamBuddy" line was misleading (no UI path existed) and now describes the new search-and-provision flow. **Regression tests:** 14 unit tests in [`backend/tests/unit/services/test_ldap_service.py`](backend/tests/unit/services/test_ldap_service.py) cover the filter shape, wildcard escaping, username-canonical fallbacks (sAMAccountName → uid → cn), bind-failure propagation, the no-password-bind contract of `lookup_ldap_user`, and pin both ldap3 schema-check workarounds (`check_names=False` on the search connection + `attributes=["*"]` so OpenLDAP doesn't reject the request). 12 integration tests in [`backend/tests/integration/test_ldap_provision.py`](backend/tests/integration/test_ldap_provision.py) cover auth gating, short-query rejection, LDAP-disabled rejection, `already_provisioned` annotation, the 4xx/5xx error matrix, and a happy-path provision that verifies `auth_source=ldap`, `password_hash=None`, and group-mapping inheritance from the auto-provision path. 5 frontend tests in [`LdapUserPicker.test.tsx`](frontend/src/__tests__/components/LdapUserPicker.test.tsx) cover the debounce, the search → select → provision flow, already-provisioned rows rendering disabled, and surfaced provision errors. 65 LDAP-related backend tests + 5 picker tests pass; full backend ruff clean; frontend build clean.
- **Slice modal: pick the build plate (#1337, reported by @digitalskies)** — Slicing a plain STL through the integrated slicer always defaulted to whatever `curr_bed_type` lived in the chosen process preset (typically `Cool Plate`), which the slicer CLI then rejected for high-temp filaments with `Plate 1: Cool Plate does not support filament 1`. The user had no way to switch plates short of cloning the process preset in BambuStudio, which defeats the point of the in-app slicer. The Slice modal now exposes a `Build plate` dropdown with the six canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate, Smooth PEI Plate) plus an explicit `Auto (use process preset)` option that preserves the previous behavior. The dropdown sits between Process profile and Filament rows so it stays visible regardless of how many filament slots the picked plate uses (a long filament list would otherwise push it off the modal's `max-h-[85vh]` scroll viewport) and is **always enabled** — including when the user picks a Printer Preset Bundle from the top BundlePicker. When the user picks a specific plate, the new `bed_type` field on `SliceRequest` ([`backend/app/schemas/slicer.py`](backend/app/schemas/slicer.py)) flows through the dispatcher via two paths: (1) **resolved-preset path** — the route helper `_patch_process_bed_type` in [`backend/app/api/routes/library.py`](backend/app/api/routes/library.py) overwrites `curr_bed_type` on the resolved process JSON before forwarding to the sidecar (no preset cloning required); (2) **bundle dispatch path** — `slice_with_bundle` in [`backend/app/services/slicer_api.py`](backend/app/services/slicer_api.py) adds a `bedType` form field to the sidecar multipart so the sidecar can pass `--curr_bed_type` through to the CLI, which lets the override take effect even though Bambuddy can't patch the bundle's process JSON locally (the sidecar materialises it from the stored .bbscfg). Sidecar versions that don't recognise the field silently no-op — the slice still runs, just with the bundle's default plate; the slicer-API fork at maziggy/orca-slicer-api will need the matching change for the bundle path to take full effect. **i18n parity:** 8 new keys (`slice.bedType.{label,auto,coolPlate,coolPlateSuperTack,engineering,highTemp,texturedPEI,smoothPEI}`) added to all 8 locales — full German translation, English fallbacks elsewhere per project convention. **Regression tests:** 4 in [`test_slice_request_bed_type.py`](backend/tests/unit/test_slice_request_bed_type.py) (`bed_type` defaults to None, accepts the six canonical strings, rejects overlong input via the schema's `max_length=64`; `_patch_process_bed_type` overwrites an existing value, adds the field when missing, and returns the input unchanged for malformed JSON or non-dict roots), 4 in [`test_library_slice_api.py`](backend/tests/integration/test_library_slice_api.py) (resolved-preset path: with `bed_type` set, the sidecar receives `"curr_bed_type": "Textured PEI Plate"` in the presetProfile multipart part; without it, `curr_bed_type` stays out of the body entirely. bundle dispatch path: `bedType` form field carries the override through to the sidecar; omitting `bed_type` keeps the form field out of the request so the bundle's own `curr_bed_type` is preserved), 2 in [`SliceModal.test.tsx`](frontend/src/__tests__/components/SliceModal.test.tsx) (dropdown selection puts `bed_type` on the request; leaving it on Auto omits the field). 59 backend slice tests + 34 SliceModal tests pass; build and i18n parity script clean.

### Fixed
- **In-app updater no longer fails with "Failed to fetch updates" when a tag on the remote was re-pointed** — Symptom on native installs upgrading from 0.2.4: clicking *Settings → System → Updates → Apply Update* aborted with `Git fetch failed: From https://github.com/maziggy/bambuddy ... ! [rejected] v0.2.1 -> v0.2.1 (would clobber existing tag)` even though `origin/main` and the target release tag fetched cleanly. `_perform_update` in [`backend/app/api/routes/updates.py:526`](backend/app/api/routes/updates.py) ran `git fetch --prune --tags origin`, which returns a non-zero exit if even one local tag would be overwritten by a moved upstream tag — and any non-zero exit was surfaced to the user as a hard failure, leaving them stuck on the previous release. **Fix:** added `--force` to the fetch invocation so a re-pointed tag on the remote overwrites the local stale copy cleanly; matches the in-app updater's contract ("sync me to the remote"). The native `update.sh` doesn't hit this bug because it fetches without `--tags` at all, but the in-app path can't drop `--tags` — release-tag refs (`v0.2.4b1`, `v0.2.4.1`, …) need to be locally resolvable for the subsequent `git reset --hard`. **Note for users on 0.2.4 upgrading to 0.2.4.1:** the fix is *in* 0.2.4.1, which 0.2.4's updater can't reach. Use the CLI path documented in the release notes for this one upgrade; from 0.2.4.1 onward the in-app button works again. Docker installs are unaffected — they don't go through git. Regression test in [`backend/tests/integration/test_updates_api.py`](backend/tests/integration/test_updates_api.py) asserts `--force` is in the fetch args alongside the existing `--tags` assertion so a future refactor can't quietly drop it.

- **Print Queue page no longer 404-storms thumbnail / plates / plate-thumbnail when an item points at a soft-deleted archive, and pending queue items for a soft-deleted archive are now cancelled with a clear reason instead of silently stuck-pending forever** ([#1348](https://github.com/maziggy/bambuddy/issues/1348) follow-up) — Symptom: opening the Queue page or any of its sub-views fired `GET /archives/{id}/thumbnail`, `GET /archives/{id}/plates`, and `GET /archives/{id}/plate-thumbnail/{n}` for queue rows pointing at archives that had been soft-deleted (#1343 leaves the row but removes files from disk), all returning 404. Frontend's `onError` handler hid the broken `<img>` so it was visually clean, but the network tab and any pending-print logic still saw three 404s per affected row. Two underlying problems wearing one mask: cosmetic 404 storm, AND functional — a queue item whose 3MF was removed from disk can never actually dispatch, so it sits in `pending` forever with no clue to the user about why. **Two-part fix in the same shape as the print-log followup above:** (1) New helper `_cancel_pending_queue_items(db, archive_id)` in [`backend/app/services/archive.py`](backend/app/services/archive.py) sets `status='cancelled'` + `waiting_reason='Source archive deleted'` on every pending queue item linked to the archive; called from `soft_delete_archive` alongside the existing `_null_print_log_thumbnail_paths` cleanup. Only `pending` is touched — `printing` is a rare race that the printer-side fail-path catches anyway, and completed / failed / cancelled rows are historical audit-trail. Hard-delete is already covered by `ON DELETE CASCADE` on `print_queue.archive_id`. (2) Queue API serializer in [`backend/app/api/routes/print_queue.py:224`](backend/app/api/routes/print_queue.py) now checks `item.archive.deleted_at` before populating any archive-derived field — when soft-deleted, the whole block (`archive_name`, `archive_thumbnail`, `print_time_seconds`, `filament_used_grams`, plate-specific re-reads, …) is skipped and the new `archive_deleted: bool = True` flag on [`PrintQueueItemResponse`](backend/app/schemas/print_queue.py) signals the soft-deleted state. The `archive_thumbnail` suppression alone covers the thumbnail render in [`QueuePage.tsx:434`](frontend/src/pages/QueuePage.tsx), [`CompactHistoryRow.tsx:45`](frontend/src/components/CompactHistoryRow.tsx), and [`QueueTimelineView.tsx:72`](frontend/src/components/QueueTimelineView.tsx) because they all gate on it. The `/plates` query at [`QueuePage.tsx:329`](frontend/src/pages/QueuePage.tsx) was gated on `archive_id` only — `archive_id` is the real FK and stays exposed in the response (the queue scheduler still needs it for audit / dispatch checks), so the query was added a new `&& !item.archive_deleted` clause to respect the new flag. **Regression tests** in [`backend/tests/integration/test_print_queue_api.py`](backend/tests/integration/test_print_queue_api.py): `test_soft_delete_archive_cancels_pending_queue_items` pins the cancel-only-pending behavior (completed rows untouched), `test_queue_api_hides_archive_surface_when_soft_deleted` pins the suppression + `archive_deleted=True` for soft-deleted archives, `test_queue_api_still_exposes_archive_surface_when_live` pins the sanity guard that live archives' fields keep flowing through. All 3 new tests + 146 in the queue/archives/obico sweep pass; ruff clean.

- **Print log no longer 404-storms on the thumbnail endpoint for entries whose archive was deleted or whose print failed before a thumbnail was extracted** ([#1348](https://github.com/maziggy/bambuddy/issues/1348) follow-up) — Symptom in DevTools when opening Archives → Print Log: a 404 per orphaned entry per render. Visually clean (the `<img onError>` handler in [`frontend/src/pages/ArchivesPage.tsx:3763`](frontend/src/pages/ArchivesPage.tsx) hides the broken image), but noisy and wasteful. **Root cause** in [`backend/app/api/routes/print_log.py:91-113`](backend/app/api/routes/print_log.py): `PrintLogEntry.thumbnail_path` is copied by value from `archive.thumbnail_path` at write-time ([`main.py:3615`](backend/app/main.py)) but the FK on `archive_id` is `ON DELETE SET NULL` (#1378) — so log entries survive archive deletion to preserve stats history, but the cached thumbnail_path string keeps pointing at a file that was removed when the archive's directory was deleted. Same shape for failed prints that recorded an expected thumbnail path before the extractor wrote (or skipped writing) the file. **Two-part fix:** (1) `get_print_log_thumbnail` self-heals when the file is missing — it NULLs `thumbnail_path` on the entry and commits before returning 404, so the frontend's `entry.thumbnail_path && <img>` gate keeps the next page render from re-requesting. (2) New helper `_null_print_log_thumbnail_paths(db, archive_id)` in [`backend/app/services/archive.py`](backend/app/services/archive.py) is called from `soft_delete_archive` and `delete_archive` before the on-disk files are removed — eager clear so future deletes don't cause the one-time storm at all. The route handler covers stragglers (failed prints, files manually moved, etc.). **Regression tests** in [`backend/tests/integration/test_archives_api.py`](backend/tests/integration/test_archives_api.py): `test_soft_delete_clears_thumbnail_path_on_linked_log_entries` pins the eager clear on the soft-delete route, `test_hard_delete_clears_thumbnail_path_before_fk_cascade` pins it on `ArchiveService.delete_archive` (used by the auto-purge sweeper), `test_print_log_thumbnail_route_lazy_nulls_missing_file` pins the route's self-heal for failed-print orphans where the file was never written. All 4 new tests + 61 in the archives/print-log sweep pass; ruff clean.

- **Camera stream no longer freezes every ~30 s when Obico fault detection is enabled on the same printer the user is viewing** ([#1348](https://github.com/maziggy/bambuddy/issues/1348), reported by @SL666) — Symptom on an X1-class printer running firmware that allows only one concurrent camera connection: opening the live camera worked initially, then the stream hung within seconds and "cancelled" — clicking the in-UI refresh restored it for another few seconds before it hung again, ad infinitum. Disabling Obico fault detection made the stream stable. **Root cause** in `_capture_frame` at [`backend/app/services/obico_detection.py:209-220`](backend/app/services/obico_detection.py): the buffer-reuse path that was supposed to make Obico polling free (#1271) only worked when `_last_frames[printer_id]` was populated. In every race window where `_active_streams` had a registered entry but the JPEG buffer was empty — stream startup before the first frame lands (1–3 s on RTSP), or any moment the upstream ffmpeg was mid-reconnect after a 30 s read timeout — `try_get_active_buffered_frame(printer_id)` returned `None` and the caller fell through to `capture_camera_frame_bytes(...)`, which spawned its own ffmpeg + TLS proxy and opened a competing RTSP socket on the printer. On firmwares that only allow one camera connection, that second socket forced the printer to drop the live fan-out connection — viewers' ffmpeg hit its 30 s read timeout, looped through 30 reconnect attempts at 0.2 s each (all racing the next Obico poll 10 s later), exhausted retries, and the broadcaster pump exited. The user's viewer disconnected with no obvious cause in the log because the Obico capture itself looked successful (`Successfully captured camera frame bytes: 83484 bytes`). **Fix:** the buffer-reuse gate was widened from "do we have a frame in the buffer?" to "is any fan-out stream registered for this printer?" — even when the buffer is momentarily empty. New helper `is_stream_active(printer_id) -> bool` at [`backend/app/api/routes/camera.py:82`](backend/app/api/routes/camera.py) checks `_active_streams` / `_active_chamber_streams` independently of buffer state. `_capture_frame` now consults `is_stream_active` first: if True, it returns the buffered frame when available or `None` (skip this poll cycle) when not — it **never** opens a competing socket while a viewer is attached, regardless of buffer state. The poll loop retries 10 s later, by which time the buffer is virtually guaranteed to be populated. The fresh-socket path still fires unchanged when no viewer is connected (Obico's primary use case: detection on idle/headless prints). Cost of the fix: at most one missed Obico detection cycle per viewer-attach (~10 s lag); benefit: zero competing-socket events while any viewer is connected. `try_get_active_buffered_frame` was refactored to delegate to `is_stream_active` so the two helpers stay in lockstep — its `/camera/snapshot` caller at [`camera.py:859`](backend/app/api/routes/camera.py) is unchanged behaviorally (snapshot is user-initiated single-shot; falling through to a fresh capture if buffer is momentarily empty is the desired behavior there). **Regression tests** in [`backend/tests/unit/test_obico_detection.py`](backend/tests/unit/test_obico_detection.py): new `test_skips_poll_when_stream_active_but_buffer_empty` reproduces the exact race (viewer registered, buffer empty) and pins that `_capture_frame` returns `None` and `capture_camera_frame_bytes` is NOT called; existing `test_returns_buffered_frame_when_stream_active` and `test_falls_back_to_fresh_capture_when_no_stream` were updated to patch the new `is_stream_active` helper alongside `try_get_active_buffered_frame`. All 31 obico tests + 113 in the wider camera/obico sweep pass; ruff clean.

- **Reprints (including failed and cancelled ones) no longer overwrite the source archive's statistics; Quick Stats now adds per-print events** ([#1378](https://github.com/maziggy/bambuddy/issues/1378), reported by @IndividualGhost1905) — Symptom: after reprinting a model from the Archive page, the reprint contributed nothing to Quick Stats / Statistics; worse, if the reprint failed at 10g while the original print used 100g, the archive card and the totals both flipped from 100g to 10g — losing the original print's data. **Root cause** in [`backend/app/main.py:1973`](backend/app/main.py) (`_handle_print_start`): every reprint shared the source archive's row via the `register_expected_print`/`expected_archive_id` path, and statistics (`GET /archives/stats` in [`backend/app/api/routes/archives.py`](backend/app/api/routes/archives.py), Prometheus `/metrics` in [`backend/app/api/routes/metrics.py`](backend/app/api/routes/metrics.py)) summed PrintArchive columns — so a single archive row was the only contribution per file regardless of how many times the user pressed Reprint. The cost overwrite at [`usage_tracker.py:633`](backend/app/services/usage_tracker.py) and energy overwrite at [`main.py:3625`](backend/app/main.py) compounded it: each run's actuals replaced the previous run's values on the source row. **Architectural fix** — statistics are now event-based, not file-based. The existing `PrintLogEntry` table (one row per print event, written at print completion; lives in [`backend/app/models/print_log.py`](backend/app/models/print_log.py) and already powered the cross-archive /print-log page) gains six columns: `archive_id` (nullable FK, `ON DELETE SET NULL` so log entries survive archive deletion — preserving the #1343 soft-delete-vs-stats decoupling), `cost`, `energy_kwh`, `energy_cost`, `failure_reason`, `created_by_id`. Idempotent SQLite + Postgres migrations in [`backend/app/core/database.py`](backend/app/core/database.py). `/archives/stats` and `/metrics` now sum/count from `PrintLogEntry` joined to `PrintArchive` for time-accuracy comparisons and user-scope filters. The `_run_reprint_archive` flow still keys to the source archive (so the archive list stays one-card-per-file rather than ballooning into one card per print), but every print completion writes a new `PrintLogEntry` row with the run's actual filament / time / cost / energy / status / failure_reason. The cost overwrite in `usage_tracker.py` now only fires on the first run (counts existing `PrintLogEntry` rows for the archive); the energy background task at `main.py:3625` similarly preserves the source archive's energy_kwh on reprints while backfilling the run's energy on the matching `PrintLogEntry` row (it runs after `write_log_entry`, so it fetches and updates the latest log row for the archive). **New UX surface** — archive list responses gain four aggregate fields (`run_count`, `last_run_at`, `total_filament_actual_grams`, `successful_run_count`, `failed_run_count`) computed via a single batch query (`_load_run_aggregates` in [`backend/app/api/routes/archives.py`](backend/app/api/routes/archives.py)); the archive card renders an orange "N prints" badge when `run_count > 1`, with a tooltip breaking down successful vs failed runs. New endpoint `GET /archives/{archive_id}/runs` returns every PrintLogEntry for the archive (newest first), powering a new Print Log section at the top of the Edit Archive modal — date / status / duration / filament / cost columns plus failure_reason text under failed runs (new component [`frontend/src/components/PrintLogTable.tsx`](frontend/src/components/PrintLogTable.tsx)). i18n keys added under `archives.card.runsBadge*` and `archives.runLog.*` (en/de/ja translated; other 5 locales fall back to English per project convention). **Soft-delete contract preserved** — the `purge_stats=true` hard-delete path now also `DELETE`s linked PrintLogEntry rows so the archive's contribution truly disappears from totals; soft-delete (the default) leaves the log entries intact so #1343 stats-preservation still works. **Partial-print accuracy** — failed / cancelled / stopped reprints would have over-counted in the new stats path if PrintLogEntry just recorded the source archive's slicer estimate (100g for a print that stopped at 10g). The write_log_entry call site now uses a partial-aware filament value via the new `_compute_run_filament_grams` helper at [`backend/app/main.py`](backend/app/main.py): completed prints record the estimate (since the print finished), and partial prints prefer sum of tracked spool deltas from `usage_results` if inventory was set up, falling back to `estimate × progress%` from the MQTT push, and finally to None if no signal exists. The per-run cost write uses the same precedence: prefer the usage_results sum (raw — without the topup-to-estimate inflation that `usage_tracker.update_archive_usage` applies for archive.cost, which assumes the print completed), and only fall back to archive.cost for completed prints. **Test fixture update** — the `archive_factory` conftest fixture now synthesizes one `PrintLogEntry` per completed test archive (since stats moved to that table, the previous "create archive only" pattern would silently produce 0-stat tests); pass `with_run=False` to skip for the "archived but never printed" case. 3 integration tests in [`backend/tests/unit/test_archive_run_aggregation.py`](backend/tests/unit/test_archive_run_aggregation.py) pin the reporter's exact scenario (100g completed + 10g failed reprint → stats show 110g total / 2 prints / 1 successful / 1 failed), the archive-list aggregates wiring, and the `/runs` endpoint ordering. 14 unit tests in [`backend/tests/unit/test_run_filament_helper.py`](backend/tests/unit/test_run_filament_helper.py) lock the partial-print math across completed / failed / cancelled / stopped statuses, both inventory-tracked and untracked paths, multi-filament sums, the >100% progress clamp, and the None-fallback cases. The full backend test suite — 4921 tests across unit + integration — passes; ruff clean; frontend build clean.

- **Matplotlib no longer logs `Permission denied: /app/.config` on every container start** — Matplotlib (imported lazily by the STL thumbnail generator in [`backend/app/services/stl_thumbnail.py`](backend/app/services/stl_thumbnail.py) when a user uploads an `.stl` file to the library) tries to create its font/style cache at `$HOME/.config/matplotlib` on first import. `HOME` is pinned to `/app` in the Dockerfile so containers with `pwd.getpwuid()` failures (PUID-mapped uids without a local passwd entry) still have a writable home — but `/app` itself is root-owned and not writable by the PUID:PGID the entrypoint drops to, so the first STL upload after a container start logged an `EPERM` warning and matplotlib fell back to a fresh `/tmp/matplotlib-*` dir. Functionally harmless (thumbnails still rendered) but it cluttered every support bundle and forced matplotlib to re-scan system fonts on every restart (~1-2 s per first-STL-upload). **Fix:** added `ENV MPLCONFIGDIR=/tmp/matplotlib` to the Dockerfile so matplotlib uses a guaranteed-writable cache dir up front. `/tmp` is writable by any uid so this works regardless of PUID, and the cache survives the container's lifetime so the font scan only pays its cost once per container.

- **BambuStudio now sees AMS / vt_tray / net info from a virtual printer without requiring a printer power-cycle** ([#1371](https://github.com/maziggy/bambuddy/issues/1371), reported by @Andlar94) — Symptom on a non-proxy VP (the user's A1 in `print_queue` mode): connecting BambuStudio to the VP showed no AMS / external spool info on the Device page; the only workaround was to power the printer off and back on while BambuStudio was open, after which the info populated for one window. **Root cause** in `MQTTBridge._on_printer_raw` at [`backend/app/services/virtual_printer/mqtt_bridge.py`](backend/app/services/virtual_printer/mqtt_bridge.py): the bridge's cache of the real printer's `push_status` was `self._latest_print_state = copy.deepcopy(print_data)` — a wholesale replacement on every incoming push. Bambu firmware sends two shapes of `push_status`: full pushall responses (on `pushall` request / printer reconnect) include AMS / vt_tray / net.info / lights_report, and ~1 Hz incremental updates with just the fields that changed (temperatures, fan speeds, wifi signal). The first incremental push after a pushall therefore wiped AMS info from the bridge cache, and BambuStudio (which reads the cache via the VP's own 1 Hz status push) saw a stripped-down state with no AMS visible until the next pushall — typically only on a manual printer power-cycle, which forces Bambuddy to reconnect and re-issue `pushall`. **Fix:** in the cache update path, preserve a small set of "slicer-visible sticky" top-level keys from the previous cache when the incoming push doesn't include them: `ams`, `vt_tray`, `ams_extruder_map`, `mapping`, `net`, `ipcam`, `lights_report`. Mirrors the same preservation pattern Bambuddy itself already uses for its own internal `state.raw_data` at [`bambu_mqtt.py:2686-2711`](backend/app/services/bambu_mqtt.py); without that, even Bambuddy's own UI would have shown blank AMS in the same way after an incremental push. The new sticky-key set adds three entries (`net`, `ipcam`, `lights_report`) that the slicer specifically cares about: BambuStudio reads `net.info[*].ip` for the FTP destination IP (which the bridge then rewrites to the VP bind IP), uses `ipcam.rtsp_url` for the camera mirror, and renders `lights_report` for the chamber-light toggle. Note: the fix only covers the typical "incremental push omits the sticky key" case — if a future firmware sends a *partial* AMS list (e.g. only one unit's tray subset), that incoming partial push would still replace the cached AMS. That's a rarer scenario and would need full Bambuddy-style per-unit deep-merge; deferred until anyone hits it. **Regression tests** in [`backend/tests/unit/test_vp_mqtt_bridge.py`](backend/tests/unit/test_vp_mqtt_bridge.py): new `test_incremental_push_preserves_ams_from_previous_cache` seeds the cache with a full pushall payload (AMS + vt_tray + lights_report), fires a temps-only incremental push, and pins that all three sticky fields survive with their original values; new `test_incoming_ams_update_replaces_cached_ams` pins the counterpart — when an incoming push DOES include `ams`, the cached value is replaced, so the preservation doesn't shadow real AMS state changes. All 29 mqtt_bridge tests + 158 in the wider virtual-printer sweep pass; ruff clean.

- **Queue items no longer get permanently stuck in `printing` status when the printer was in `FINISH` state at dispatch time, and direct-dispatch (Library → Print) no longer reports false success in the same scenario** ([#1370](https://github.com/maziggy/bambuddy/issues/1370), reported by @Martinnygaard) — Symptom: queue page shows `Busy: <printer>` even though the printer is connected, idle, and `awaiting_plate_clear=False`; no new prints will dispatch to it until the user manually deletes or reassigns the queue row. Reproducible by queueing (or directly dispatching) onto a printer that still has the un-dismissed "Print complete" prompt from a prior job. **Root cause** in `_watchdog_print_start` at [`backend/app/services/print_scheduler.py`](backend/app/services/print_scheduler.py) **and the parallel `_verify_print_response` at [`backend/app/services/background_dispatch.py`](backend/app/services/background_dispatch.py)**: the post-dispatch verifiers both treated *any* `gcode_state` transition away from `pre_state` as proof that the printer had accepted the `project_file` command. In the reporter's bundle, item 6 dispatched while printer 3 was in `FINISH` (residual from item 3 earlier that day) — firmware silently rejected the new `project_file` because the previous-print prompt was still up, and ~2 minutes later the user manually dismissed the screen prompt, putting the printer into `IDLE`. The watchdog saw `state != pre_state` and returned early as "command landed", but `FINISH → IDLE` is the user dismissing a prompt, **not** the printer accepting our project_file — so the queue row stayed at `'printing'` indefinitely and the scheduler's busy-printer seed (`SELECT printer_id FROM print_queue WHERE status='printing'` in [`print_scheduler.py:166-171`](backend/app/services/print_scheduler.py)) permanently marked printer 3 as busy. The same broad-transition bug existed in `_verify_print_response`, which would have caused direct-dispatch (Library → Print) onto a FINISH-state printer to report false success — silently failing to print while the UI showed the dispatch as complete. **Fix:** in both verifiers, narrow the "command landed" check to an allow-list of active-print states (`PREPARE` / `SLICING` / `RUNNING` / `PAUSE`) instead of "any state that isn't `pre_state`". Inactive states (`IDLE`, `FINISH`, `FAILED`) no longer short-circuit the early return. The `subtask_id`-advance signal stays as-is in both verifiers — it remains the definitive "command landed" path for H2D firmware that sits at `FINISH` for ~50 s after accepting `project_file` before transitioning to `PREPARE` (#1078 stays green in both). **Resilience hardening alongside the fix:** the watchdog's revert commit and `printer_manager._persist_awaiting_plate_clear` now run through `run_with_retry` ([`backend/app/core/database.py`](backend/app/core/database.py)), so SQLite single-writer `database is locked` contention can't silently drop the queue-row revert or the plate-clear gate flag. The revert path returns a tristate sentinel (`"reverted"` / `"already_moved_on"` / `"revert_failed"`) so the post-revert MQTT session-recovery logic only runs when we actually reverted (or the commit failed) — never when `on_print_complete` had already cleared the row, where a forced reconnect could break a healthy concurrent print on the same printer. (Most other queue/archive writes already went through `run_with_retry`; these two were the holdouts that surfaced as repeated `Failed to persist awaiting_plate_clear` warnings in the reporter's bundle.) **Manual recovery for users on 0.2.4** who already have stuck rows: stop Bambuddy, then `sqlite3 /app/data/bambuddy.db "UPDATE print_queue SET status='cancelled', completed_at=datetime('now') WHERE status='printing';"` and restart. **Regression tests** — new `test_reverts_on_finish_to_idle_user_dismissed_prompt` (queue) and `test_returns_false_on_finish_to_idle_user_dismissed_prompt` (direct-dispatch) reproduce the exact reporter scenario on both code paths; new `test_does_not_revert_on_pickup_via_active_state` (queue) and `test_returns_true_on_each_active_print_state` (direct-dispatch) iterate all four active-print states (PREPARE/SLICING/RUNNING/PAUSE) and pin that each one is correctly treated as a valid "command landed" signal. Existing `test_no_revert_if_item_already_completed` was also hardened — it now uses a real client mock and asserts `force_reconnect_stale_session.assert_not_called()`, so the tristate-sentinel guard around the recovery path is pinned (catches the regression I introduced and then fixed during the audit pass). The pre-existing `test_exits_on_state_change` (uses `RUNNING`) and `test_exits_on_subtask_id_change_even_if_state_still_finish` (the #1078 H2D path) both still pass without modification. All 29 watchdog tests across both files + 411 in the scheduler/queue/dispatch/printer-manager sweep + 3161 in the full backend unit suite + 302 in the targeted integration sweep all pass; ruff clean.

- **Spool removal from AMS no longer requires a manual Reconnect on X1C printers that report `power_on_flag=False` while idle** ([#1365](https://github.com/maziggy/bambuddy/issues/1365), reported by @an3k via @maziggy) — On an X1C running firmware 01.08.02.00, pulling a spool out of an AMS slot left the slot showing as full in Bambuddy until the user clicked "Reconnect"; even re-reading the (now empty) slot's RFID from the printer screen didn't propagate. **Root cause:** the empty-slot detection in [`_handle_ams_data`](backend/app/services/bambu_mqtt.py) at [`bambu_mqtt.py:1721`](backend/app/services/bambu_mqtt.py) gated on `if tray_exist_bits_str and power_on:` — meaning *any* MQTT message with `power_on_flag=False` was skipped wholesale. That guard was added in [`488f6631`](https://github.com/maziggy/bambuddy/commit/488f6631) to fix [#765](https://github.com/maziggy/bambuddy/issues/765), where a printer's final shutdown message (all-zero `tray_exist_bits` + `power_on_flag=False`) was wiping AMS slot data and triggering auto-unlink. But this user's X1C firmware emits `power_on_flag=False` between prints with `tray_exist_bits` still reflecting the real slot inventory — so every spool-removal update was silently discarded and only the manual Reconnect (which sends `pushall`, a full per-tray snapshot independent of the bitfield path) would correct the view. **Fix:** narrow the skip to the exact shutdown pattern — zero bits **AND** `power_on_flag=False`. Non-zero `tray_exist_bits` with `power_on_flag=False` is valid idle-AMS state and the update is now applied. The original #765 regression test (`test_shutdown_message_preserves_ams_data`) uses `tray_exist_bits='0'` and therefore still passes, so the shutdown protection is preserved exactly. **New regression test** `test_idle_printer_with_power_off_and_nonzero_bits_clears_removed_slot` in [`backend/tests/unit/services/test_bambu_mqtt.py`](backend/tests/unit/services/test_bambu_mqtt.py) pins the #1365 behavior: a removal update with non-zero bits and `power_on_flag=False` clears the affected slot, with other slots untouched. All 250 bambu_mqtt tests pass; ruff clean.

- **Discord notification provider now accepts legacy `discordapp.com` webhook URLs** ([#1363](https://github.com/maziggy/bambuddy/issues/1363), reported by @mrfoureyed) — Discord's "Copy Webhook URL" button emits `https://discordapp.com/api/webhooks/...` while Bambuddy's validation in [`backend/app/services/notification_service.py`](backend/app/services/notification_service.py) only accepted `https://discord.com/api/webhooks/...`, raising "Invalid Discord webhook URL" on paste. Both hostnames are operational on Discord's side and serve the same webhooks. The validation now accepts either prefix; the check itself is retained (vs. removing it as suggested) because it still catches the common paste-the-wrong-thing-into-the-Discord-field error. **Regression tests** in [`backend/tests/unit/services/test_notification_service.py`](backend/tests/unit/services/test_notification_service.py) — new `TestDiscordProvider` class pins both hostnames accepted, non-Discord hosts rejected, empty URL rejected.

- **Multi-color print archives reported near-zero cost (e.g. $0.01 for 110g) when only some AMS trays were mapped to inventory spools** ([#1344](https://github.com/maziggy/bambuddy/issues/1344), reported by @nicktags) — On an H2C running multi-color prints from Bambuddy with the global default filament cost set to $10/kg, the reporter's 110.3g archive showed $0.01 instead of ~$1.10. **Root cause:** `archive.cost` was set in two stages — first in [`backend/app/services/archive.py:1100-1114`](backend/app/services/archive.py) at archive creation (total grams × primary filament `cost_per_kg`, which produced the correct ~$1.10), then **overwritten** in [`backend/app/services/usage_tracker.py:618-621`](backend/app/services/usage_tracker.py) with `sum(r.cost for r in results)` where `results` only contains AMS trays mapped to a spool in Bambuddy's inventory. On a multi-color print where 3 of 4 used trays had no inventory spool, the sum only included the one tracked slot's tiny share, e.g. 1g × $10/kg = $0.01. The overwrite logic (#505, Feb 2026) was correct for fully-tracked single-color prints but silently corrupted multi-color archives when inventory was incomplete. The multi-color slicer feature that shipped in 0.2.4 ([`988c0055`](https://github.com/maziggy/bambuddy/commit/988c0055)) made this state common — many more users started running multi-filament prints from Bambuddy without first setting up inventory entries for every tray. **Fix:** the overwrite block in `usage_tracker.py` now charges any filament weight not covered by an inventory spool at the global default rate. New computation: `total_cost = sum(tracked_costs) + (archive.filament_used_grams - sum(tracked_weights)) × default_filament_cost / 1000`. Fully-tracked prints are unchanged (untracked grams = 0, top-up = 0). Partial-tracked prints get the missing slots' grams charged at the default rate, so the archive reflects the whole print. Same correction applied to the manual rescan path in [`backend/app/api/routes/archives.py`](backend/app/api/routes/archives.py) (`update_metadata` and `recalculate-costs` both now read `SUM(SpoolUsageHistory.weight_used)` alongside `SUM(cost)` and top up by the untracked delta). The pre-#1344 `archive.cost not overwritten with zero` regression test stays green — when `total_cost` after top-up is still 0 (no inventory match, no default rate set), the pre-existing catalog-based cost is preserved. **Regression tests** in [`backend/tests/unit/test_cost_tracking.py`](backend/tests/unit/test_cost_tracking.py): `test_archive_cost_includes_untracked_filament_at_default_rate` — 110g archive, only 10g tracked by inventory at $10/kg, default rate $10/kg → archive.cost = $1.10 (was $0.01 pre-fix; this is the exact reporter scenario). `test_archive_cost_fully_tracked_unchanged_by_topup` — when tracked weight ≥ archive grams, no top-up is applied and cost is unchanged from the pre-fix sum. All 14 cost-tracking tests + 182 in the wider usage-tracker / archives / cost-statistics suites pass; ruff clean.

- **Plate-detection calibration captured the wrong camera when an external camera was configured** ([#1359](https://github.com/maziggy/bambuddy/issues/1359), reported by @Andlar94) — On the reporter's A1 with an external RTSP / go2rtc camera enabled, every print start raised "Build plate not empty" no matter how perfectly they calibrated. **Root cause:** the runtime auto-check at print start in [`backend/app/main.py:1819`](backend/app/main.py) called `check_plate_empty(..., use_external=printer.external_camera_enabled, ...)` — honouring the external camera setting. The manual UI check + calibration routes in [`backend/app/api/routes/camera.py`](backend/app/api/routes/camera.py) declared `use_external: bool = False`, and the frontend client at [`frontend/src/api/client.ts`](frontend/src/api/client.ts) always sent `use_external=false` explicitly (the UI call sites in [`PrintersPage.tsx`](frontend/src/pages/PrintersPage.tsx) never passed `useExternal`). So calibration captured a frame from the **built-in** chamber camera and saved it as the reference; the runtime auto-check captured a frame from the **external** camera and diffed it against that built-in reference — a permanent difference well above any sane threshold, hence "not empty" on every print. **Fix:** the two routes now use `use_external: bool | None = None`, and after the printer row is loaded they derive the default as `bool(printer.external_camera_enabled and printer.external_camera_url and printer.external_camera_type)` — identical to the runtime path's logic and the service-layer gate at [`plate_detection.py:605`](backend/app/services/plate_detection.py). Centralising the default on the backend means any current or future caller automatically gets the right camera without having to remember the flag. The frontend client now only forwards `use_external` when the caller explicitly sets it (default omitted → backend decides), so the existing UI buttons immediately benefit. Power-user override path stays open: passing `?use_external=false` on a printer with an external camera still wins, so anyone who deliberately wants a built-in-camera reference can still get one. **Regression tests** in [`backend/tests/integration/test_camera_api.py`](backend/tests/integration/test_camera_api.py): `test_check_plate_defaults_use_external_when_external_camera_enabled` and `test_calibrate_plate_defaults_use_external_when_external_camera_enabled` pin the new default for a printer with external camera + URL + type set; `test_check_plate_defaults_use_external_false_when_external_camera_disabled` pins the built-in default for the no-external-camera case (the common path stays untouched); `test_calibrate_plate_explicit_use_external_false_overrides_default` pins the explicit-override escape hatch. All 11 plate-tagged camera integration tests pass; ruff clean; frontend build clean.

- **API Keys page now exposes a narrowly-scoped "Update electricity price" toggle so the Home Assistant dynamic-tariff integration actually works** ([#1356](https://github.com/maziggy/bambuddy/issues/1356), reported by @maziggy) — The reporter followed the [Energy Tracking wiki](https://wiki.bambuddy.cool/features/api-keys/) page literally — "create a key with **Write Settings** permission, then PATCH `/api/v1/settings` with `{energy_cost_per_kwh: ...}`" — and hit `{"detail":"API keys cannot be used for administrative operations"}`. Triage showed three independent drifts: (1) the wiki listed nine fictional permissions ("Read Printers / Write Settings / Admin / …") but the actual UI in [`SettingsPage.tsx:3683-3744`](frontend/src/pages/SettingsPage.tsx) only ever exposed **four** toggles (Read Status, Manage Queue, Control Printer, Allow Cloud Access). There was no Write Settings toggle to tick. (2) Even if the UI had exposed it, the backend hard-denies `Permission.SETTINGS_UPDATE` for every API key via `_APIKEY_DENIED_PERMISSIONS` in [`backend/app/core/auth.py`](backend/app/core/auth.py) — intentional protection because `PATCH /settings` can rewrite SMTP/LDAP/MQTT credentials and the HA access token, which would silently widen attack surface beyond what any documented use case needs. (3) So the wiki had been promising a workflow that was never deliverable. **Fix:** introduce a narrowly-scoped door for exactly the documented use case rather than relaxing the deny list. New column `can_update_energy_cost BOOLEAN DEFAULT FALSE` on `api_keys` ([`backend/app/models/api_key.py`](backend/app/models/api_key.py)) with idempotent migration in [`backend/app/core/database.py`](backend/app/core/database.py) — defaults FALSE so existing keys never silently gain settings-write capability on upgrade. New endpoint `POST /api/v1/settings/electricity-price` in [`backend/app/api/routes/settings.py`](backend/app/api/routes/settings.py) accepts `{"energy_cost_per_kwh": <float ≥ 0>}` — the field name matches what the wiki already documented so the HA `rest_command` example needs only a URL+method change, not a payload change. New custom dependency `require_energy_cost_update()` in [`backend/app/core/auth.py`](backend/app/core/auth.py) bypasses the `_APIKEY_DENIED_PERMISSIONS` check **for this one route** for API keys with `can_update_energy_cost=True`; JWT users still go through the standard `SETTINGS_UPDATE` permission check; auth-disabled deployments allow it (matches other settings routes). Crucially, the general `PATCH /settings` route remains denied for API keys — flipping the narrow flag does NOT widen general settings-write access (regression test pins this). Schema/route wiring in [`backend/app/schemas/api_key.py`](backend/app/schemas/api_key.py) + [`backend/app/api/routes/api_keys.py`](backend/app/api/routes/api_keys.py) accepts and returns the new field on create/update/list. **Frontend:** fifth toggle "Update electricity price" added to the create-API-key card in [`SettingsPage.tsx`](frontend/src/pages/SettingsPage.tsx) with an amber "Energy" badge on existing keys that have it set; `APIKey` / `APIKeyCreate` / `APIKeyUpdate` types in [`api/client.ts`](frontend/src/api/client.ts) gained the new field; 16 new i18n keys (`updateEnergyCost`, `updateEnergyCostDescription`, `energyCostBadge`) added to all 8 locales — full German translation, English fallbacks elsewhere per project convention. **Wiki rewrites:** [`features/api-keys.md`](https://wiki.bambuddy.cool/features/api-keys/) — replaced the fictional 9-row permissions table with the actual 5 toggles plus an info box explaining why no general Write Settings / Admin exists. [`features/energy.md`](https://wiki.bambuddy.cool/features/energy/) — Home Assistant section now points at `POST /api/v1/settings/electricity-price`, instructs users to tick the new permission, and adds a deprecation warning for users who built the integration from the old (broken) `PATCH /settings` example. **Tests:** [`backend/tests/integration/test_settings_electricity_price.py`](backend/tests/integration/test_settings_electricity_price.py) — 8 tests covering create-with-flag, default-off, API-key-with-flag updates persist, API-key-without-flag → 403, JWT admin user with SETTINGS_UPDATE allowed, anon → 401, negative price → 422 (Pydantic `ge=0`), and the critical regression test `test_patch_settings_still_denied_with_energy_flag` that pins the narrow-flag-doesn't-widen-PATCH contract. [`frontend/src/__tests__/pages/SettingsPage.test.tsx`](frontend/src/__tests__/pages/SettingsPage.test.tsx) — 2 new tests: Energy badge renders for keys with the flag, the toggle's value flows through to the POST body when the box is ticked. All 8 new backend tests + 32/32 SettingsPage tests pass; ruff clean; i18n parity passes; frontend build clean.

- **Layer timelapse now starts for queue/VP-dispatched prints** ([#1353](https://github.com/maziggy/bambuddy/issues/1353), reported by @Andlar94) — Reporter's external camera + go2rtc setup was configured correctly (Obico was happily polling the snapshot URL for ML plate detection) but no MP4 was ever produced. Logs showed `[LAYER-TL] Stitching layer timelapse for printer 1` after each print yet no frames were ever captured and no `[LAYER-TL] Attaching timelapse...` follow-up appeared. Root cause: `layer_timelapse.start_session()` was only called from the two **new-archive** paths in `on_print_start` (`backend/app/main.py:2510` fallback path and `2600` regular new-archive). The **expected-archive** branch at `main.py:1981-2052` — where every reprint and every queue/VP-dispatched print lands — updated the existing archive's status to `printing` but never started a timelapse session. So `_background_layer_timelapse` ran at print-complete time, called `tl_complete(printer_id)`, found no active session in `_active_sessions`, silently returned `None`, and the wrapper at `main.py:3917` produced no log message for the no-session case. Every print that came through the queue (or any reprint) silently lost its timelapse. **Fix:** mirror the same `if printer.external_camera_enabled and printer.external_camera_url: start_session(...)` call in the expected-archive branch right after `_active_prints` registration. The two pre-existing paths are untouched. **Help-text correction:** the snapshot URL field's tooltip previously read *"Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection"* — which is technically true but read as if filling in the URL was sufficient to enable those features. Reworded across all 8 locales to *"Timelapse and plate detection each require their own per-printer toggle — this URL is just the image source they pull from when active"* so admins know they still need to enable plate detection per-printer (separate toggle) and that timelapse only fires while a print is running. **Regression tests** in [`backend/tests/unit/test_layer_timelapse_expected_archive.py`](backend/tests/unit/test_layer_timelapse_expected_archive.py): `test_expected_archive_path_starts_timelapse_when_external_camera_enabled` exercises the full `on_print_start` flow with a registered expected print + `external_camera_enabled=True` and asserts `start_session` is called with the expected-print archive_id (not a freshly created one); `test_expected_archive_path_skips_timelapse_when_external_camera_disabled` keeps the existing gate in place so we don't try to capture from a None URL. 2 new tests pass; ruff clean; frontend i18n parity passes; bundle builds.
- **Assign Spool now configures the slot even after a "Reset Slot" on A1 Mini BMCU / P1S Standard AMS** ([#1322](https://github.com/maziggy/bambuddy/issues/1322) follow-up, reported by @RosdasHH) — The original fix widened empty-slot detection to `state == 11 OR tray_type != ""`, which closed the configured-slot reconfig case (PETG-over-PLA) but didn't help the "Reset Slot on printer screen with spool still inserted" flow: on these firmwares the AMS reports `state=3, tray_type=""` after a Reset Slot regardless of whether a spool is physically loaded. The empty-detection therefore decided "empty", skipped the MQTT publish, marked the assignment pending, and waited for `on_ams_change` to re-fire when the AMS transitioned to "loaded" — but the AMS never transitioned, because nothing was changing physically. A deadlock with no escape from user actions. Reporter pinned it by removing the `if not slot_is_empty:` gate at [`backend/app/api/routes/inventory.py:1302`](backend/app/api/routes/inventory.py) and verified the firmware accepts the MQTT push when a spool is present, even with `state=3, tray_type=""`. The original guard's rationale — "Bambu firmware silently drops ams_filament_setting / extrusion_cali_sel for unloaded slots" — turned out to be over-cautious: it's load-bearing only for slots that the *firmware itself* explicitly marks empty via `state == 9` ("no spool") or `state == 10` ("spool present but no feed"). For ambiguous states (`state=3` default-idle, missing-state on older firmwares), the AMS doesn't give us a reliable signal at all, so the safest bet is to treat the user's explicit Assign click as their assertion that a spool is there and let the firmware decide what to do with the push. **Fix:** the empty-detection now only short-circuits on `state ∈ {9, 10}` — every other state attempts MQTT. `pending_config` is now driven by either the explicit-empty signal OR `not configured` (so a printer-offline / no-client publish failure still flags the assignment as awaiting follow-up). The `on_ams_change` replay logic at [`backend/app/main.py:1031`](backend/app/main.py) is unchanged and still serves as the safety net for state=9/10 slots whose spools get inserted later (and for any truly-empty slot the firmware dropped — DB `fingerprint_type` stays empty until an AMS push actually provides one, so the replay still fires). **Trade-off:** for the rare case of "assign to a slot that really IS empty + state=3", the badge will show "Configured" even though firmware silently dropped the push. Most users assign right after inserting, so this is a small UI honesty cost in exchange for unblocking the much more common Reset-Slot workflow. **Follow-up optimization (also @RosdasHH):** the reporter then traced the raw MQTT payload and found that P1S / A1 Mini send only `{"id": N}` for a genuinely-empty slot — no `state`, no `tray_type`, no other fields. Without that signal, the assign path was firing one wasted MQTT publish per click on a truly-empty slot (firmware dropped it silently, but still). The AMS parser at [`backend/app/services/printer_manager.py:788`](backend/app/services/printer_manager.py) now detects the bare-tray shape (`len(tray) == 1 and "id" in tray and state is None`) and promotes it to `state=9` — the firmware's explicit "no spool" code — which lets the inventory route's existing `state ∈ {9, 10}` short-circuit apply. The detection is intentionally narrow: the post-Reset-Slot A1 Mini BMCU case sends a populated payload with empty values (state=3, tray_type=""), which has more than one key and stays unaffected — so the #1322 root fix is preserved. **Regression tests** in [`backend/tests/integration/test_inventory_assign.py`](backend/tests/integration/test_inventory_assign.py): `test_post_reset_slot_with_state_3_still_fires_mqtt` (renamed from the previous "marks_pending" test which was pinning the bug) and `test_state_missing_with_empty_tray_type_still_fires_mqtt` (inverted from the legacy "older firmware empty → pending" assertion) pin the new behavior on the two firmware shapes the reporter hit. `test_empty_tray_type_without_state_still_fires_mqtt` covers the no-state SpoolBuddy case. `test_no_ams_data_with_no_client_marks_pending` keeps the printer-offline path producing `pending_config=True` so on_ams_change replay still triggers. `test_state_empty_skips_mqtt_and_marks_pending` (state=9) is unchanged — the firmware's explicit "no spool" still short-circuits correctly. The recent [`dd3e3f80`](https://github.com/maziggy/bambuddy/commit/dd3e3f80) k-profile fix was a separate red-herring path the reporter happened to also hit during testing; it stays as-is. All 28 inventory-assign tests + 312 inventory-tagged tests pass; ruff clean. **Bare-tray follow-up tests:** `test_bare_tray_emulates_state_9` and `test_populated_payload_with_empty_state_3_is_not_promoted` in [`backend/tests/unit/services/test_printer_manager.py`](backend/tests/unit/services/test_printer_manager.py) — the second one is the explicit guard against regressing the #1322 root case by accident.

- **Firmware update dialog now survives Cloudflare-blocked or transient outages on `bambulab.com`** ([#1350](https://github.com/maziggy/bambuddy/issues/1350), reported by @K1ngJony) — User's X1C on 01.10.00.00 saw "01.11.02.00 newer · Unavailable" plus the error "Firmware file for 01.11.02.00 is not available from Bambu Lab", and the logs showed repeated `Failed to get Bambu Lab page: 403` warnings. Two problems stacked: (1) `https://bambulab.com/en/support/firmware-download/all` (the page Bambuddy scrapes to extract the Next.js `buildId` used to fetch per-model JSON with download URLs) was returning 403 from the reporter's network — Cloudflare bot protection on bambulab.com is stricter than on the wiki and, prior to the 2026-05-12 compliance audit, the firmware-check service still claimed to be Chrome 120 via UA spoofing. The UA was updated to honest `Bambuddy/1.0` in that audit but `Accept` / `Accept-Language` headers were never sent, so the request still tripped the "bare Python client" signal. (2) The `buildId` was cached in-memory only (1 h TTL), so every backend restart forced a fresh page fetch — meaning the first 403 from the user's network permanently broke download-URL resolution for that session even though the previous run had a perfectly valid buildId. **Fix in [`backend/app/services/firmware_check.py`](backend/app/services/firmware_check.py):** (a) the httpx client now sends `Accept: text/html,application/json,*/*;q=0.8` and `Accept-Language: en-US,en;q=0.9` alongside the existing honest `Bambuddy/1.0` UA — both headers any normal client sends, no impersonation. (b) `_get_build_id()` gained a disk-cache layer at `<data_dir>/firmware/build_id.json`: successful fetches persist `{build_id, fetched_at}` to disk; the in-memory cache (fresh path, 1 h TTL) is checked first, then the disk cache seeds the in-memory slot on cold start, then the live fetch tries to refresh. On 403 or network error, we keep the cached buildId and set a new `download_page_unreachable` flag so callers can render an honest error. (c) `_fetch_all_versions_from_download_page` now retries **once** when a cached buildId returns 404 (Bambu rebuilt the page → invalidate + refetch + retry); on 403 it sets the unreachable flag and gives up gracefully without churning. **Better error message in [`backend/app/services/firmware_update.py`](backend/app/services/firmware_update.py):** when a wiki-listed version has no download URL because `download_page_unreachable` is true, the dialog now says `"Could not reach Bambu Lab's firmware download page to fetch the file URL for X. Version is listed on the Bambu wiki but the download endpoint is unreachable from this network. Try again later, or download the firmware manually from bambulab.com and copy it to the printer's SD card."` instead of the misleading `"Firmware file for X is not available from Bambu Lab"` (which implied Bambu didn't have the file, when actually we just couldn't reach Bambu). Version genuinely not in the catalog still gets the original message. **Regression tests** in [`backend/tests/unit/test_firmware_versions.py`](backend/tests/unit/test_firmware_versions.py): `test_client_headers_identify_honestly_and_send_browser_accept` pins UA + Accept headers, `test_build_id_is_persisted_to_disk` confirms the disk write on success, `test_build_id_falls_back_to_disk_on_403` reproduces the reporter's 403 with a pre-seeded disk cache, `test_download_page_unreachable_flag_set_on_403_json` covers the per-model JSON endpoint 403 path, `test_download_page_retries_once_when_buildid_stale` proves the 404 retry. All 12 firmware tests + ruff clean.

- **Subtype dropdown on the Add/Edit Spool form now offers `CF` (carbon fiber) and `GF` (glass fiber)** ([#1345](https://github.com/maziggy/bambuddy/issues/1345), reported by @maziggy) — The Subtype dropdown in [`frontend/src/components/spool-form/FilamentSection.tsx`](frontend/src/components/spool-form/FilamentSection.tsx) is populated from the `KNOWN_VARIANTS` array in [`frontend/src/components/spool-form/constants.ts`](frontend/src/components/spool-form/constants.ts). `CF` and `GF` were missing, so a user adding a third-party PETG-CF spool via the Material=PETG + Subtype=CF flow (the same shape Bambu's "PETG HF" already used) couldn't find the subtype in the list and had to type it freehand into the "create new" tail. Added both — `CF` to match `PETG-CF` / `PLA-CF` / `ASA-CF` / `PA-CF`, and `GF` as the natural pair for `ABS-GF` / `PA6-GF`. `parsePresetName` in [`spool-form/utils.ts`](frontend/src/components/spool-form/utils.ts) is unaffected: its materials list is iterated longest-first, so a cloud preset like `Bambu PETG-CF Black` still resolves to material=`PETG-CF` with empty afterMaterial (the variant loop runs on `""` and finds nothing — no accidental Material=PETG / Subtype=CF rewrite). Frontend build clean.

- **Spool-assignment dialog stacks correctly: the material-mismatch confirmation appears above its parent, and dashboard filament hover popovers no longer get covered by sibling printer cards** ([#1336](https://github.com/maziggy/bambuddy/issues/1336) follow-up, mismatch case reported by @RosdasHH) — Two stacking-context regressions surfaced after the original z-50 → z-[100] bump on `AssignSpoolModal` landed. **(1) Material-mismatch ConfirmModal hidden behind its parent.** Assigning a spool with a different material to the one configured on the slot opens a yellow warning ConfirmModal from inside `AssignSpoolModal`. ConfirmModal's overlay was hardcoded to `z-50` in its wrapper at [`frontend/src/components/ConfirmModal.tsx`](frontend/src/components/ConfirmModal.tsx), so once the parent moved to `z-[100]` the child sat behind it — the user clicked Assign, saw the parent dim slightly, and nothing visible to confirm. Added an optional `overlayZIndex?: string` prop to `ConfirmModal` (defaults to `z-50` so all 82 other call sites are untouched), and the mismatch site at [`AssignSpoolModal.tsx:584`](frontend/src/components/AssignSpoolModal.tsx) passes `overlayZIndex="z-[110]"` so the warning sits above its parent. **(2) `FilamentHoverCard` / `EmptySlotHoverCard` covered by neighbouring printer cards.** Hovering an AMS slot on the dashboard opens a "Jade White · Bambu PETG HF · K Factor 0.024 · 87% · Open in Inventory / Configure" popover. The popover was using `position: absolute` with `z-[60]` inside its trigger — but each printer card on the dashboard creates its own stacking context (any `filter: drop-shadow` / `transform` / positioned-with-z descendant is enough), and `z-index` does not cross stacking-context boundaries: the next card in DOM order always wins regardless of how high the inner z-index goes. Visible as a "Jade White" tooltip getting half-eaten by the AMS-C tile column on the right neighbour card. Fixed by **portaling both hover cards to `document.body`** ([`FilamentHoverCard.tsx`](frontend/src/components/FilamentHoverCard.tsx) via `createPortal` from `react-dom`) with `position: fixed` and screen-space coordinates computed from `triggerRef.current.getBoundingClientRect()`. Coords are recomputed on visibility change, on `scroll` (capture phase) and on `resize` so the popover tracks the trigger when the viewport moves; a `requestAnimationFrame` re-measure after the initial paint avoids a one-frame flicker before the card has its rendered dimensions. Hover handlers wired on both the trigger AND the portaled card so moving the cursor from the slot tile onto the popover doesn't auto-dismiss it after 100 ms. The smart top/bottom placement logic (flips to below the trigger when there's not enough headroom above the fixed 56 px header) is preserved, as is the arrow pointer that points back at the slot. `z-[60]` stays — but it's now global because the popover lives at the root of the DOM, so it always beats dashboard widgets without conflicting with full-screen modals at `z-[100]`. All 20 `FilamentHoverCard`, 17 `ConfirmModal`, and 13 `AssignSpoolModal` tests pass; frontend build clean.

- **Deleting a print archive no longer wipes its filament / time / cost / energy contribution from Quick Stats** ([#1343](https://github.com/maziggy/bambuddy/issues/1343), reported by @IndividualGhost1905) — Running the same model ten times and then deleting nine of the resulting archive entries (to keep the file list tidy) silently rewound the totals on the Statistics page: `total_prints`, `total_filament_grams`, `total_cost`, and per-print energy all dropped back to whatever the surviving archive contributed, as if the other nine prints had never happened. Root cause: every metric in `get_archive_stats` at [`backend/app/api/routes/archives.py`](backend/app/api/routes/archives.py) is recomputed on each render via `COUNT` / `SUM` over the live `PrintArchive` rows, so removing a row removes its contribution. (Energy in the default "Total" mode already survived archive deletion because it reads the smart-plug lifetime counters via `_sum_live_plug_totals` — that's the architectural shape we now generalise to the rest of the metrics.) **Fix: soft delete with opt-in hard purge.** New nullable `deleted_at` column on `print_archives` ([`backend/app/models/archive.py`](backend/app/models/archive.py)) tracks rows the user removed from the UI. The DELETE endpoint at [`backend/app/api/routes/archives.py`](backend/app/api/routes/archives.py) now accepts `?purge_stats=true`; **default** behaviour is to soft-delete — files removed from disk (still frees the storage), row hidden from listings, but the row stays in the table so the stats endpoint keeps counting it. Setting `?purge_stats=true` falls back to the original hard-delete path for the rare case where the user actually wants the row out of Quick Stats too (e.g. failed prints that shouldn't pollute success-rate dashboards). The migration in [`backend/app/core/database.py`](backend/app/core/database.py) adds the column dialect-conditionally — `DATETIME` on SQLite, `TIMESTAMP` on PostgreSQL (PG doesn't accept `DATETIME` on `ALTER TABLE` the way it tolerates it inside `CREATE TABLE`) — plus an index on `deleted_at` so the `WHERE deleted_at IS NULL` filter that's now sprinkled across the listing queries stays cheap on big archive tables. **Service-layer changes.** `ArchiveService.soft_delete_archive` is a new sibling of `delete_archive` that reuses the existing on-disk path-safety checks (extracted into `_resolve_archive_dir_for_delete` so soft and hard delete share the resolution rules — refuses paths outside `archive_dir`, refuses depth-zero paths) and flips `deleted_at = now()` after `shutil.rmtree`. Listing methods now filter `PrintArchive.deleted_at.is_(None)`: `ArchiveService.list_archives`, `get_duplicate_hashes_and_names` (a soft-deleted dupe must not inflate a group's count so the UI shows "1 of 1" instead of "1 of 10"), `find_duplicates` (both the exact-hash and the print-name paths), and `ArchiveComparisonService.find_similar_archives` (both name-match and content-hash paths so the "Similar archives" panel doesn't suggest something the user just removed). The stats endpoint deliberately keeps NO filter — the whole point of #1343. Route-level reads tightened too: `GET /archives/{id}` returns 404 on soft-deleted rows so stale bookmarks don't expose hidden archives, search (both the SQLite FTS5 path and the LIKE fallback) skips them, the duplicate-group enrichment query in `list_archives` filters them, and tag listing / archives-by-tag exclude them. `GET /archives/slim` and `GET /archives/stats/export` intentionally do NOT filter so the dashboard widgets in `StatsPage.tsx` keep aggregating across the full history. **Frontend.** `ConfirmModal` gained an optional `children` slot (`frontend/src/components/ConfirmModal.tsx`) so the delete-confirmation dialog can render an opt-in checkbox between the message and the action buttons without forcing a new bespoke modal. [`frontend/src/pages/ArchivesPage.tsx`](frontend/src/pages/ArchivesPage.tsx) — both the card view and the detail view — now own a `deletePurgeStats` boolean per component instance and pass it through to `api.deleteArchive(id, purgeStats)` ([`frontend/src/api/client.ts`](frontend/src/api/client.ts) appends `?purge_stats=true` only when the box is ticked). The checkbox resets to off on every modal close so the destructive option is opt-in *per delete*, never sticky. **i18n:** one new key `archives.modal.deletePurgeStats` added across all 8 locales — full German translation, English fallbacks elsewhere per project convention. **Tests** added to [`backend/tests/integration/test_archives_api.py`](backend/tests/integration/test_archives_api.py): soft delete preserves the row's contribution to total prints / filament / cost (the regression test for the reporter's exact scenario), `?purge_stats=true` drops it from Quick Stats as before, soft-deleted archives 404 on `GET /archives/{id}`, soft-deleted archives are skipped by the search endpoint. All 42 pre-existing archive integration tests stay green, including `test_delete_archive` (which already asserts post-delete 404 — semantically equivalent under soft delete). Frontend `ConfirmModal` (17 tests) and `ArchivesPage` (23 tests) suites green, full build clean.

- **OIDC provider login icons now render again — the strict SPA CSP no longer breaks them** ([#1333](https://github.com/maziggy/bambuddy/issues/1333), [PR #1342](https://github.com/maziggy/bambuddy/pull/1342) by @netscout2001) — When an admin configured an OIDC provider with an external `icon_url` (e.g. `https://google.com/icon.png`), the login page showed the browser's broken-image glyph instead of the IdP logo. Root cause: the SPA ships with the strict policy `img-src 'self' data: blob:` so the entire admin UI cannot hot-link arbitrary external image hosts; admin-supplied icon URLs hit that wall on every render. Two options were on the table — loosen `img-src` to allow `https:` (one-line change but degrades the SPA's CSP everywhere), or proxy the bytes through the backend (this PR). The proxy path was chosen because (a) the SPA's `img-src` policy stays strict app-wide; (b) the existing MakerWorld thumbnail endpoint at [`backend/app/services/makerworld.py`](backend/app/services/makerworld.py) already established the pattern with the same rationale; (c) anonymous login-page renders no longer leak each visitor's IP to the IdP host as a tracking signal — the proxy fetches the bytes once at admin-configure time and serves them from the same origin afterwards. **Backend.** New `MakerWorld-style` fetcher in [`backend/app/services/oidc_icon.py`](backend/app/services/oidc_icon.py) streams the response with `follow_redirects=False` (so the SSRF host allowlist can't be bypassed via a 302 to a private address), enforces a MIME whitelist (PNG/JPEG/WebP/GIF; SVG is intentionally omitted — XML payloads carry too many `xlink:href` / external-ref corner cases for an MVP), and aborts at the **first chunk past 1 MB** so a hostile or misconfigured IdP serving a 500 MB payload cannot OOM the server. SSRF guard `assert_safe_public_https_url` in [`backend/app/api/routes/_oidc_helpers.py`](backend/app/api/routes/_oidc_helpers.py) is **stricter** than the Spoolman variant — Spoolman deliberately allows loopback / RFC-1918 (same-LAN deployment is the standard topology) while OIDC icons must live on the public internet, so private addresses there are SSRF probes. The shared SSRF data (cloud-metadata IP set covering AWS/GCP/Azure/Oracle/DO/Alibaba, numeric-encoded-IP regex, IPv4-mapped-IPv6 unwrap) was extracted to [`backend/app/api/routes/_url_safety.py`](backend/app/api/routes/_url_safety.py) so the two top-level guards share *data* but keep their distinct *policies*. The Pydantic `_validate_icon_url` in [`backend/app/schemas/auth.py`](backend/app/schemas/auth.py) now lazy-imports the runtime SSRF guard so schema validation and the fetcher enforce the same allowlist — no drift between layers. **Storage.** Three new columns on `oidc_providers` ([`backend/app/models/oidc_provider.py`](backend/app/models/oidc_provider.py)): `icon_data` (`LargeBinary`, `deferred=True` so list queries don't pull the BLOB on every login-page render), `icon_content_type` (`String(20)`, also serves as the has-icon indicator so the check never accidentally lazy-loads the BLOB), `icon_etag` (SHA-256 hex). A DB-layer `CheckConstraint` enforces the all-or-nothing triplet (`(icon_data IS NULL) = (icon_content_type IS NULL) = (icon_etag IS NULL)`) — fresh installs (SQLite + PostgreSQL) get it via `metadata.create_all`, stale PostgreSQL installs get it via `ALTER TABLE ADD CONSTRAINT` in [`backend/app/core/database.py`](backend/app/core/database.py) (SQLite cannot `ADD CONSTRAINT` on an existing table, same trade-off as the existing `default_group_id` FK). The migration's `ALTER TABLE` is dialect-conditional — `BLOB` on SQLite, `BYTEA` on PostgreSQL. **Routes.** Four endpoints in [`backend/app/api/routes/mfa.py`](backend/app/api/routes/mfa.py): `GET /oidc/providers/{id}/icon` is **public** (no auth, same rationale as `/api/v1/makerworld/thumbnail` — `<img>` tags can't send Authorization headers, and the icon renders before the user is signed in), serves cached bytes with a strong `ETag` and `Cache-Control: public, max-age=3600`, supports `If-None-Match` including the `W/` weak prefix, the `*` wildcard, and multi-token comma lists per RFC 7232. `DELETE /oidc/providers/{id}/icon` clears **all four** icon columns (URL + the three cached-bytes columns) — "Remove icon" means the whole record is gone, not just the cache, so the admin form doesn't end up in a confusing half-state where it shows a stale URL while the login page renders the Shield fallback. `POST /oidc/providers/{id}/icon/refresh` re-fetches from the stored URL for the "Refresh" button. Disabled providers respond 404 on the GET endpoint to avoid leaking their existence to anonymous callers. `POST` / `PUT` integrate the fetcher **transactionally**: a failed fetch aborts with 400 before commit, so a bad URL on create leaves no half-configured row in the DB and a bad URL on update leaves the previous cached bytes intact. PUT with explicit `icon_url: null` clears the icon record (detected via Pydantic's `model_fields_set` — distinct from "field omitted" which preserves it). Both fetch failures and SSRF rejections log at WARNING with the URL **redacted** (query string and fragment stripped via `_redact_url_for_log`) so admin-supplied presigned URLs carrying `X-Amz-Signature=...` or bearer tokens can't end up in operator log files. **Frontend.** [`frontend/src/pages/LoginPage.tsx`](frontend/src/pages/LoginPage.tsx) extracts an `OIDCProviderButton` sub-component so each provider owns its own `iconFailed` state — on `<img>` error (provider deleted between page load and image fetch, network blip, etc.) the SPA swaps in the `Shield` fallback rather than showing the broken-image glyph to anonymous users. [`frontend/src/components/OIDCProviderSettings.tsx`](frontend/src/components/OIDCProviderSettings.tsx) does the same with `ProviderIconAvatar` (Globe fallback) and adds Refresh / Remove buttons. The new same-origin proxy URL helper `api.oidcProviderIconUrl(id)` returns a `SameOriginUrl`-branded string so a future caller can't accidentally substitute an attacker-controlled URL where this is consumed. Four new i18n keys (`refreshIcon`, `removeIcon`, `iconRefreshed`, `iconRemoved`, `iconFetchFailed`) added across all 8 locales. **Tests.** About 100 new tests covering the streaming fetcher (MIME whitelist, status codes, redirect rejection, size-cap early-exit including the **first-oversized-chunk** guarantee, missing Content-Type distinct message, `httpx.InvalidURL` mapping), the OIDC SSRF guard (explicitly asserts that Spoolman-allowed cases like loopback / RFC-1918 / `localhost` are **rejected** here so the two guards do not silently converge), Pydantic-validator parity (numeric-encoded IPs, cloud metadata, multicast, IPv4-mapped IPv6 all rejected at schema-validate time), the dialect-conditional `ALTER TABLE` migration (both BLOB and BYTEA paths via patched `is_sqlite()`), the full create/update/delete/refresh flow including atomicity (failed fetch preserves prior state), the upgrade-path edge case (`icon_url` present but no cached bytes → refetch on next save), ETag/304 with `W/` weak prefix and `*` wildcard, raw-SQL inconsistent-triplet 404 defence, the PG→SQLite-ZIP backup BLOB type-mapping round-trip, and a **CSP regression-guard test** in [`backend/tests/integration/test_security_headers.py`](backend/tests/integration/test_security_headers.py) that asserts the SPA default CSP block does **not** include `https:` in `img-src` — so a future contributor "fixing" a broken icon by relaxing CSP discovers the proxy pattern instead. Frontend tests in [`LoginPage.test.tsx`](frontend/src/__tests__/pages/LoginPage.test.tsx) and [`OIDCProviderSettings.test.tsx`](frontend/src/__tests__/components/OIDCProviderSettings.test.tsx) cover `has_icon: true|false`, mixed providers on the same page, `<img>` error → Shield/Globe fallback, and **per-provider state isolation** (two `has_icon: true` providers; firing `error` on A leaves B's icon intact — locks in the sub-component extraction so a future hoist of `useState` to the parent loop is caught by CI). Manually verified end-to-end against a live PocketID instance with multiple icon URLs. **Follow-on tightening:** `has_icon` is now a required field on `OIDCProviderResponse` (no Pydantic default — fails loudly if any future caller skips `_build_provider_response`), backed by an `OIDCProvider.has_icon` `@property` reading `icon_content_type`. In `update_oidc_provider` the icon refetch was moved BEFORE the setattr loop, so on fetch failure the in-memory ORM object stays consistent (DB row was already safe via `get_db()`'s rollback; this closes the in-memory window too). **Patched by @netscout2001.**

- **Backup tab indicator dot now turns green when Scheduled (local) Backups is enabled** ([#1331](https://github.com/maziggy/bambuddy/issues/1331), [PR #1338](https://github.com/maziggy/bambuddy/pull/1338) by @chanakyan-arivumani) — Toggling **Scheduled Backups** on inside Settings → Backup left the sidebar tab indicator dot stuck on grey: the visual cue that there's an active backup configuration was lost for users who run scheduled local backups without GitHub. Two stacked layers caused it: (1) the dot condition at [`SettingsPage.tsx:1461`](frontend/src/pages/SettingsPage.tsx) only checked the GitHub chain (`cloudAuthStatus?.is_authenticated && githubBackupStatus?.configured && githubBackupStatus?.enabled`); `settings?.local_backup_enabled` was never consulted, so the scheduled-backup state had no path to the indicator. (2) The toggle handler in [`GitHubBackupSettings.tsx`](frontend/src/components/GitHubBackupSettings.tsx) called `api.updateSettings({ local_backup_enabled })` but never invalidated the `['settings']` query cache, so `SettingsPage` kept reading the stale value — the indicator would only update on a full page reload even if the condition fix were in place. Two-line fix: extend the dot's predicate to `... || settings?.local_backup_enabled` and add `queryClient.invalidateQueries({ queryKey: ['settings'] })` after a successful save (matching the existing invalidation pattern at `GitHubBackupSettings.tsx:402/463/477/497`). The GitHub-chain short-circuits first so the common case is unchanged. **Patched by @chanakyan-arivumani.**

- **Color catalog presets now apply `extra_colors` (gradient stops) and `effect_type` (sparkle / wood / marble / glow / matte) onto the spool, not just hex + name** ([#1340](https://github.com/maziggy/bambuddy/issues/1340), reported by @maugsburger) — Creating a catalog entry that pairs a base color with multi-color gradient stops and a visual effect, then clicking that swatch in the Edit Spool dialog, only copied `color_name` and `rgba` over — the `extra_colors` and `effect_type` fields were silently dropped. The data was flowing from the backend correctly (`GET /api/v1/inventory/color-catalog` returns both fields per the `ColorCatalogEntry` schema in [`frontend/src/api/client.ts`](frontend/src/api/client.ts)), but three layers above stripped them: (1) [`SpoolFormModal.tsx`](frontend/src/components/SpoolFormModal.tsx) typed its `colorCatalog` state with a narrower shape that omitted the two fields; (2) [`ColorSection.tsx`](frontend/src/components/spool-form/ColorSection.tsx) mapped catalog entries to `CatalogDisplayColor` (the typed-down shape rendered on swatches) without propagating them; (3) the `selectColor()` handler only set `rgba` + `color_name` on click. **Fix:** widened both types in [`spool-form/types.ts`](frontend/src/components/spool-form/types.ts) (`CatalogDisplayColor` + `ColorSectionProps.catalogColors`) to carry the optional `extra_colors` and `effect_type`, propagated them through the four `matchedCatalogColors` mapping callbacks (byBrand / exact full-material / normalized-trailing-`+` / base-material prefix), and extended `selectColor` to take optional `extraColors` / `effectType` parameters. **Semantic rule:** catalog swatches are complete presets — picking one writes BOTH gradient and effect from the entry (overwriting any existing values), so a gradient catalog entry applies its stops AND a solid catalog entry clears any old gradient that was on the spool. Recent-colors and the hardcoded-fallback palette are plain hex pickers — picking one keeps any existing `extra_colors` / `effect_type` untouched, since those swatches aren't presets, just color picks. **Bonus:** fixed the en-US spelling drift the reporter flagged in their nitpick — `'Extra colours'` and `'wrong colour loaded'` strings (which had been seeded into all 8 locale files as English fallbacks) standardized to `'Extra colors'` and `'wrong color loaded'`; matching comment blocks (`// Multi-colour ...`) normalized in the same pass. **Regression tests** in [`__tests__/components/spool-form/ColorSectionCatalogExtras.test.tsx`](frontend/src/__tests__/components/spool-form/ColorSectionCatalogExtras.test.tsx) (3 cases): catalog click with gradient + effect propagates all four fields to `updateField`, catalog click on a solid preset clears any pre-existing extras/effect (preset-replaces-look semantic), and fallback palette click leaves extras/effect untouched. All 23 spool-form tests + 8 i18n parity tests pass; build clean.

- **Assigning a spool to an unconfigured AMS slot no longer silently skips MQTT on A1 Mini / P1S firmware — and the "PETG over a PLA-configured slot won't reconfigure" symptom is fixed in the same change** ([#1322](https://github.com/maziggy/bambuddy/issues/1322), reported by @RosdasHH) — On the user's A1 Mini BMCU (firmware 01.07.02.00) and P1S Standard AMS (firmware 00.00.06.75), pressing "Assign Spool" on any slot left the slot unconfigured: the DB row was created with `pending_config=True`, the MQTT publish was skipped, and the log line `Pre-configured assignment: ... (slot empty, will configure on insert)` fired even though the spool was physically loaded. The same code path also blocked the "swap PLA to PETG in the same slot" flow — Bambuddy would keep treating the spool as PLA because the publish never reached the printer. **Root cause:** the empty-slot detection at [`backend/app/api/routes/inventory.py:1267`](backend/app/api/routes/inventory.py) preferred `tray.state == 11` ("filament fed to extruder") over `tray_type`, falling back to `tray_type` only when `state` was missing entirely. Reporter's AMS dumps showed `state == 3` on every slot — configured and unconfigured, on both printers — and `state` was never absent. So the state-only branch always fired, the result was always "empty", and MQTT was always skipped regardless of whether the slot was actually loaded. The "fingerprint_type empty → defer until insert" pre-config replay at [`backend/app/main.py:1026`](backend/app/main.py) had the same `cur_state == 11` gate, so even when the user manually configured the slot in Bambu Studio afterward (making `tray_type` go from `""` to `"PLA"`), the deferred MQTT publish never fired because state stayed at 3. **Fix:** both call sites now use a disjunction — the slot is treated as loaded when **either** `state == 11` **or** `tray_type` is non-empty. The "Reset slot" case (state=11 + tray_type="") that the original state-only check was protecting still works through the first clause; the configured-slot case (state=3 + tray_type="PLA") on firmwares that never set state=11 now works through the second; and truly empty unconfigured slots (state≠11 + tray_type="") still fall through to the pending-config path correctly. The on_ams_change replay's disjunction also fires the deferred publish when the user later configures the slot through Bambu Studio, since that flips `tray_type` non-empty even if state stays at 3. **Caveat:** for a truly empty slot with a 3rd-party non-RFID spool that the user physically inserted, neither signal points to "loaded" on these firmwares, so we still can't auto-fire the publish until the slot gets configured (manually or by another assign). The pending-config row persists in the DB and gets applied on the next AMS push that flips `tray_type` non-empty. **Regression tests:** 3 in [`test_inventory_assign.py`](backend/tests/integration/test_inventory_assign.py) — `test_state_never_eleven_firmware_with_loaded_tray_fires_mqtt` (state=3 + tray_type='PLA' → MQTT fires; pins the reporter's primary symptom and the PETG-over-PLA secondary symptom which goes through the same predicate), `test_state_never_eleven_firmware_with_empty_tray_marks_pending` (state=3 + tray_type='' still pending — confirms the disjunction didn't accidentally turn truly empty slots into the loaded branch), and `test_on_ams_change_fires_replay_when_tray_type_appears_without_state_11` (pre-existing SpoolBuddy-style assignment with empty fingerprint; tray_type going `''→'PLA'` on a state=3 firmware fires the deferred publish even though state never becomes 11). All 28 tests in the file pass; ruff clean.

- **Assign Spool / Inventory search: numeric spool ID lookup is back, and Unassign in Spoolman mode no longer stays permanently disabled** ([#1336](https://github.com/maziggy/bambuddy/issues/1336), reported by @S0liter) — Two independent regressions surfaced from the same report. **(1) Numeric ID search:** typing a Spoolman spool's numeric ID into the search box on the "Assign Spool" dialog (or on the Inventory page) returned no results. The shared search helper `spoolMatchesQuery` at [`frontend/src/utils/inventorySearch.ts:7`](frontend/src/utils/inventorySearch.ts) only checked the text fields (`material`, `brand`, `color_name`, `subtype`, `note`, `slicer_filament_name`, `storage_location`) — the spool's `id` was not part of the predicate, so a query like `12` only matched when "12" happened to be a substring of one of the text fields. One-line fix: the predicate now also tests `String(spool.id).includes(q)`, mirroring the case-insensitive substring semantics of the other fields. Covers both call sites: the Assign Spool dialog ([`AssignSpoolModal.tsx:255`](frontend/src/components/AssignSpoolModal.tsx) for local inventory + `:446` for Spoolman) and the main Inventory page ([`InventoryPage.tsx:871`](frontend/src/pages/InventoryPage.tsx)). New regression test in [`__tests__/utils/inventorySearch.test.ts`](frontend/src/__tests__/utils/inventorySearch.test.ts) pins exact-match (`'42'` → id 42), substring (`'4'` → id 42), and non-match (`'99'` → id 42 rejected) so the predicate can't drift back into "text only" silently. **(2) Unassign button stuck disabled in Spoolman mode:** opening the edit modal on a Spoolman spool that was assigned to an AMS slot left the Unassign button greyed out — the user had no way to release the spool back to "available". The modal at [`SpoolFormModal.tsx:526`](frontend/src/components/SpoolFormModal.tsx) only ever queried `api.getAssignments()` (the legacy local `spool_assignments` table) and looked up by `a.spool_id === spool.id`. In Spoolman mode the slot assignment lives in the separate `spoolman_slot_assignments` table, keyed by `spoolman_spool_id` — so the lookup always returned `undefined`, the button's `disabled={isPending || !spoolAssignment}` predicate stayed true forever, and `unassignMutation` was also pointing at the wrong API (`unassignSpool` instead of `unassignSpoolmanSlot`). Both the query and the mutation now branch on the existing `spoolmanMode` prop: Spoolman mode uses `getSpoolmanSlotAssignments()` + lookup by `spoolman_spool_id` + `unassignSpoolmanSlot(spool.id)` and invalidates the `spoolman-slot-assignments-all` / `spoolman-slot-assignments` query keys; local mode keeps the existing path unchanged. Two new regression tests in [`__tests__/components/SpoolFormModal.test.tsx`](frontend/src/__tests__/components/SpoolFormModal.test.tsx) (`SpoolFormModal — Unassign button (#1336)`): the button is enabled and clicking it calls `unassignSpoolmanSlot(42)` when a matching `spoolman_slot_assignment` exists, and the button stays disabled (no `unassignSpool` fallback) when no assignment exists. All 12 search-helper tests + 13 InventoryPage search tests + 27 SpoolFormModal tests pass; frontend build clean.

- **Spoolman auto-create no longer labels Bambu Lab RFID spools with competitor names like "3DXTECH™ Black"** ([#1309](https://github.com/maziggy/bambuddy/issues/1309), [PR #1330](https://github.com/maziggy/bambuddy/pull/1330) by @ojimpo) — When Bambuddy auto-created a Spoolman filament entry for a Bambu Lab RFID spool, the second-stage lookup against Spoolman's external library (`GET /api/v1/external/filament`, served from [SpoolmanDB](https://donkie.github.io/SpoolmanDB/filaments.json)) matched on `material + color_hex` only — there was no `manufacturer` / `vendor` filter. **The catalog is multi-vendor and roughly ID-sorted**: for PLA + `#000000` (black) it contains 64 entries, with the first hit being `3djake_pla_black_1000_175_n` (`3DJAKE`), the third being `3dxtech_pla_carbonxcarbonfiberblack_500_175_p` (`3DXTECH`, name `CarbonX™ Carbon Fiber Black`), and the actual `bambulab_pla_black_1000_175_n` not surfacing until position 15. Bambuddy therefore created the filament under the Bambu Lab vendor but labeled it with a competitor's product name. Real-world observations in production: Bambu Lab ABS Black created as `3DXTECH™ Black`, Bambu Lab PLA Support picked the adjacent / wrong variant instead of `bambulab_pla_supportforpla/petgblack_500_175_n`, and PLA Basic Black created as `PLA` (material, not `PLA Basic`). A **secondary issue compounded this**: `_create_filament_from_external` dropped the external entry's `density` field, so even when the correct entry was eventually picked the density got overwritten by `create_filament`'s built-in PLA-default 1.24 fallback instead of the catalog's actual value (1.26 for PLA Basic, 1.31 for PETG, etc.). **Fix** in [`backend/app/services/spoolman.py::_find_or_create_filament`](backend/app/services/spoolman.py): (1) the external-library loop now filters by `manufacturer == "Bambu Lab"` (case-insensitive, whitespace-trimmed), with a defensive `id.startswith("bambulab_")` fallback that handles entries where the `manufacturer` field is missing or has drifted in a future SpoolmanDB schema. (2) When multiple Bambu Lab candidates match the same `material + color_hex`, the function prefers the entry whose `name` equals the AMS `tray_sub_brands` (lowercase+strip comparison) so the more specific variant wins — `PLA Basic` over generic `Black`, `Support for PLA/PETG Black` over generic `Black`, etc. (3) `_create_filament_from_external` now propagates `external.get("density")` through to `create_filament`; when the catalog entry has no density set, the existing material-table fallback inside `create_filament` still kicks in via the `if density is None` branch at line 321 — no path lost. **Behavioural caveat the user needs to know**: previously-created mis-named filaments are NOT auto-renamed by this fix. Step 1 of `_find_or_create_filament` is the internal-Spoolman-filament loop that short-circuits on `(vendor == "Bambu Lab", material, color_hex)` — and that path is unchanged. Any Bambu Lab filament created by an older Bambuddy build (or hand-edited by the user) will continue to be matched and reused on subsequent AMS reads, regardless of how wrong its name is. To pick up the corrected name, the user has to **delete the mis-named filament in Spoolman once** — then the next AMS read for the same material+color falls through to the external-library step and creates a new entry with the correct Bambu Lab name. This is deliberate: some users may have intentionally renamed Bambu Lab filaments (e.g. to follow their own naming convention or to merge variants) and a silent auto-rename would undo that. **Regression tests** in [`test_spoolman_service.py::TestFindOrCreateFilament`](backend/tests/unit/services/test_spoolman_service.py) (6 new): internal short-circuit preserves the existing match without touching the external library, non-Bambu-Lab external entries are skipped even when they sort first in SpoolmanDB, `PLA Basic` wins over generic `Black` via the `tray_sub_brands` tiebreaker (per maintainer request on #1309), no-match-anywhere falls back to `tray_sub_brands or tray_type` instead of leaking a competitor name into the create call, `id.startswith("bambulab_")` accepts entries with absent `manufacturer` field, and density propagates end-to-end through the public method instead of getting clobbered by the material-default. All 44 tests in `test_spoolman_service.py` pass; ruff clean. **Reported and patched by @ojimpo.**

- **Safety: bed-jog Z direction was inverted on A1 / A1 Mini — "Up" rammed the nozzle into the bed** ([#1334](https://github.com/maziggy/bambuddy/issues/1334), reported by william.filipcic@gmail.com) — On A1 / A1 Mini, clicking the "Up" arrow on the printer-card bed-jog control would send the nozzle straight into the build plate. Reporter triggered it with the 50 mm step and crashed their nozzle. **Root cause:** the bed-jog UI was designed against the X1 / P1 / H2 family's bed-on-Z convention. On those printers the bed is the Z-axis, Bambu's firmware homes Z=0 at the top, and ``G1 Z-`` raises the bed toward the toolhead (decreases the nozzle-bed gap). The frontend maps "Up" to negative distance with that convention in mind. **A1 / A1 Mini are bed-slingers**: the bed moves on Y, the toolhead moves on X+Z, and the firmware uses standard cartesian Z (Z+ = toolhead up). On those models ``G1 Z-10`` drives the *toolhead* down 10 mm — straight through any clearance the user had — which is exactly what the reporter saw. There was no model classification at the bed-jog code path; every printer got the same X1-convention G-code. **Fix:** new ``is_bed_slinger(model)`` helper at [`backend/app/services/printer_manager.py`](backend/app/services/printer_manager.py) (sibling to existing ``supports_chamber_temp`` / ``has_stg_cur_idle_bug``, reuses the already-defined ``A1_MODELS`` frozenset which covers display names and internal codes ``N1`` / ``N2S``). The bed-jog route at [`backend/app/api/routes/printers.py:2710`](backend/app/api/routes/printers.py) now inverts the signed distance before emitting the G-code when the printer model is in that set, so the UI "Up" semantics ("decrease nozzle-bed gap") stay consistent regardless of which physical part moves on the printer. Frontend stays untouched — single source of truth for the direction logic lives in the backend, keyed off the printer's ``model`` column, so any future bed-slinger Bambu model only needs one frozenset update. The route's ``Query`` description and docstring now state the new contract explicitly: distance is the *gap adjustment*, not the raw Z value, and the backend translates per model. **Regression tests:** 13 in [`test_bed_jog.py::TestBedJogAPI`](backend/tests/unit/test_bed_jog.py) — 6 parametrised cases prove bed-on-Z models (X1C / P1S / H2D / H2S / H2C / P2S) still emit ``G1 Z-10.00`` for a UI "Up" click (pass-through), 6 parametrised cases prove A1 / A1 Mini / A1MINI / A1-MINI / N1 / N2S emit ``G1 Z10.00`` instead (inverted, toolhead up), plus 1 symmetric "Down arrow drops the toolhead via ``G1 Z-``" case. 5 in [`test_printer_manager.py::TestIsBedSlinger`](backend/tests/unit/services/test_printer_manager.py) pin the helper's classification contract — A1 family true, every bed-on-Z model false, None / empty-string safe, case-insensitive. **Safety note:** if you own an A1 or A1 Mini and were running any 0.2.x build before this release, do not use the printer-card bed-jog buttons — they will move the toolhead in the wrong direction. The Z controls in Bambu Studio / Bambu Handy are unaffected (they generate their own model-aware G-code).

- **Spoolman inventory: editing a spool's color name no longer "reverts" to the subtype on save** ([#1319](https://github.com/maziggy/bambuddy/issues/1319), reported by @MartinNYHC) — On Spoolman-backed inventory, changing a spool's color name in the edit dialog appeared to accept the new value but the inventory list column and the next edit-dialog open showed it back to the subtype string. **Three layers stacked on top of each other to produce this:** (1) `find_or_create_filament` at [`backend/app/services/spoolman.py:609`](backend/app/services/spoolman.py) matches existing Spoolman filaments by `material / name / color_hex / vendor` — `color_name` is intentionally not part of the match key (Spoolman doesn't standardise the field and most installs leave it null) — but when a match was found it returned the existing filament's id unchanged, silently dropping the new `color_name` value. The write never reached Spoolman. (2) On re-read, the helper at [`_spoolman_helpers.py:279`](backend/app/api/routes/_spoolman_helpers.py) falls back to `subtype` when `filament.color_name` is empty (without the fallback, Spoolman installs that don't fill the field would render every spool as "Unknown color"). The persisted value was still empty, so the read synthesised the column from `subtype`. (3) The edit form prefilled `color_name` from `spool.color_name` — which on Spoolman installs without `color_name` was the synth value (= subtype). If the user changed `subtype` but not `color_name`, the form silently round-tripped the OLD subtype back to Spoolman as if it were a real user-set `color_name`, which then started showing up as the persisted value on the next render — the exact "color reverts to subtype" pattern in the bug report. **Fixes:** (1) `find_or_create_filament` now patches the matched filament's `color_name` via the existing `patch_filament` PATCH wrapper when the request differs from what's stored. Convention on the parameter: `None` = "don't touch", `""` = explicit clear (patches Spoolman to `null`), any other string = set/update. (2) The PATCH route at [`spoolman_inventory.py:567`](backend/app/api/routes/spoolman_inventory.py) now uses Pydantic's `model_fields_set` to distinguish "field omitted" from "field explicitly set to null" — only the latter is a clear (mirrors the existing `storage_location` pattern at the same site). (3) The map helper now also returns `color_name_is_synthesized: bool` on every inventory record, and [`SpoolFormModal.tsx`](frontend/src/components/SpoolFormModal.tsx) checks it on prefill so the input starts blank when the value was synthesised from subtype — the user sees the real stored state and can't accidentally round-trip the synth value back. The read-side fallback is kept on purpose (the list-display "Unknown color" problem hasn't gone away — it's just that the form no longer treats the fallback as a real value). A `patch_filament` failure is caught and logged but doesn't block the match — the spool still links to the correct filament, only the colour-name update is dropped, which is the safer failure mode. **Regression tests:** 5 in [`test_spoolman_inventory_methods.py::TestFindOrCreateFilament`](backend/tests/unit/test_spoolman_inventory_methods.py) — patch-on-change, no-patch-when-unchanged, no-patch-when-`None`, clear-when-`""`-passed, and patch-failure-still-returns-match-id. 2 in [`test_spoolman_inventory_helpers.py::TestMapSpoolmanSpool`](backend/tests/unit/test_spoolman_inventory_helpers.py) — `color_name_is_synthesized` flag is `False` when a real value is stored, `True` when the fallback fires. 2 integration tests in [`test_spoolman_inventory_api.py`](backend/tests/integration/test_spoolman_inventory_api.py) — wire-level `color_name=null` clears (route translates to `""`), and `color_name` omitted from the PATCH body keeps the current value (route passes `None`). All 564 spoolman-tagged tests pass; ruff clean; frontend build clean.

- **Deleting an SSO user left orphan OIDC/MFA/camera-token rows on SQLite — blocked re-login and leaked auth state** ([#1285](https://github.com/maziggy/bambuddy/issues/1285), [PR #1295](https://github.com/maziggy/bambuddy/pull/1295) by @netscout2001) — On SQLite (default deployment) the `delete_user` route left orphan rows in `user_oidc_links`, `user_totp`, `user_otp_codes`, and `long_lived_tokens` because the project intentionally runs with `PRAGMA foreign_keys=OFF`, so the `ON DELETE CASCADE` declared on those tables never fired. **Reported symptom:** an admin deleted an OIDC-provisioned user, the user tried to re-login via SSO, the OIDC callback found the orphan `UserOIDCLink` pointing at the (now missing) user, failed to resolve it, and redirected to `account_inactive` instead of triggering `auto_create_users`. The same root cause was leaking MFA secrets (`user_totp`), pending email OTP codes (`user_otp_codes`), and per-user camera-stream tokens (`long_lived_tokens` — `verify()` would happily match by `lookup_prefix` even after the owning user was gone). PostgreSQL deployments were unaffected — cascade was firing there. **Fix:** mirrors the existing `APIKey` cleanup pattern in `delete_user` (introduced in PR #1182). `backend/app/api/routes/users.py:delete_user` now explicitly deletes `UserOIDCLink`, `UserTOTP`, `UserOTPCode`, and `LongLivedToken` rows owned by the user; also folds in `PrintBatch.created_by_id` cleanup (same `ondelete=SET NULL` SQLite-FK-off root cause, the `SET NULL` block at `users.py:393-407` was missing it). `backend/app/core/database.py:run_migrations` gains an idempotent startup orphan-cleanup that sweeps the four auth tables (`DELETE FROM <table> WHERE user_id NOT IN (SELECT id FROM users)`), wrapped in `begin_nested()`, logged at INFO only when rows actually drop — so installations carrying orphans from before the fix are healed automatically without manual DB intervention. No-op on Postgres (cascade already fired) and idempotent on SQLite (second run finds nothing). `backend/app/api/routes/mfa.py:list_oidc_links` returns `"<deleted>"` for `provider_name` when `link.provider` is null instead of raising `AttributeError` — covers the symmetric edge case where a `UserOIDCLink` could reference an orphaned provider. **Tests:** 14 new/extended. `test_users_auth_cleanup.py` (new): 5 tests verify `delete_user` removes OIDC/TOTP/OTP/long-lived-token rows individually + combined-cleanup atomically. `test_oidc_relogin.py` (new): full end-to-end test reproducing the #1285 symptom — mocked IdP, first OIDC login, admin delete, second OIDC login proves `auto_create_users` fires again (and pinned the regression boundary by confirming the test fails without the fix). `test_orphan_auth_cleanup_migration.py` (new): 7 tests for per-table cleanup across all four auth tables, idempotency, no-op on fresh install, and survival of rows belonging to real users. `test_mfa_api.py` adds `TestListOidcLinksDefensiveProviderNull` for the null-check. `test_auth_api.py::test_delete_user` extended to assert all five auth-table side effects (`UserOIDCLink`, `UserTOTP`, `UserOTPCode`, `APIKey`, `LongLivedToken`). All 13 PR-added tests + 194 tests in extended files pass; ruff clean. **Reported and patched by @netscout2001.**

- **Slicer bundle import 400/502/503 errors now land in the log so support bundles tell us why** ([#1312](https://github.com/maziggy/bambuddy/issues/1312), reported by @hasmar04) — Reporter hit `400 Bad Request` from `POST /api/v1/slicer/bundles` when uploading a Bambu Studio Printer Preset Bundle (`.bbscfg`); a second contributor had reported the same shape the day before. Same bundle file uploaded fine on Martin's dev machine, which strongly points at sidecar-side differences (image version, write permissions on `DATA_PATH/bundles`, TrueNAS Docker volume perms, etc.) — but triage was blocked because the sidecar's actual reject reason only made it as far as the FE toast. Bambuddy logged just the uvicorn-access line (`POST /api/v1/slicer/bundles HTTP/1.1 400`), with no detail in the support bundle. The route at `backend/app/api/routes/slicer_presets.py:import_slicer_bundle` now emits a `logger.warning` for each of the three failure shapes: **400 (`SlicerInputError`)** — sidecar's reject string is logged alongside the filename and byte count, so we can see "bundle rejected because `manifest.json` is missing" in the next support bundle without asking the reporter to copy the toast text. **503 (`SlicerApiUnavailableError`)** — logs the configured sidecar URL plus the exception detail (separates "URL wrong" from "sidecar offline"). **502 (`SlicerApiError`)** — logs filename + byte count + error string, useful when the sidecar's `DATA_PATH/bundles` write fails (the typical 5xx cause on this path). The 400 case is `WARNING` rather than `INFO` deliberately — it's an unexpected end-user-visible failure, not a routine event. Existing `test_import_bundle_sidecar_400_passes_through` now also asserts the reject reason AND the filename appear in caplog, so the support-bundle-includes-the-diagnostic contract is pinned. Doesn't fix #1312's actual root cause (sidecar-side, still under investigation with reporter) — but the next reporter we get on this code path will produce a bundle that contains the answer.

- **Restarting Bambuddy mid-print triggered plate-check pause + duplicate archive** ([#1304](https://github.com/maziggy/bambuddy/issues/1304), reported by @kleinwareio) — When a P1S print was in progress and the user updated the Bambuddy container (`latest` → `daily` in the report, but the same path fires on any restart), Bambuddy paused the live print with an "Object detected on build plate" warning AND re-archived the in-progress file as a duplicate. Root cause: the print-start detector at `backend/app/services/bambu_mqtt.py:2780` gated on `self._previous_gcode_state != "RUNNING"`, which is true whether we just saw IDLE→RUNNING (a real print start) OR we just constructed a fresh BambuMQTTClient and `_previous_gcode_state` is still its initial `None` (catch-up push from a printer already running). The fresh-client case fired `on_print_start`, which downstream ran the plate-detection-and-pause flow at `main.py` AND the FTP-download-and-archive flow — exactly the two symptoms in the bug report. Fix: added `self._previous_gcode_state is not None` to the `is_new_print` guard, so the first push from the printer in a new process lifetime never counts as a state transition into RUNNING. `_was_running` still flips to `True` via the unconditional "Track RUNNING state" block at `bambu_mqtt.py:2795`, so print-completion detection keeps working — only the start callback is suppressed. Three existing tests that asserted on the old (buggy) behavior were updated to seed `_previous_gcode_state = "IDLE"` first, matching the realistic lifecycle of a print actually starting (Bambuddy has been observing IDLE/FINISH before RUNNING); they now exercise the correct path. New regression test `test_first_running_push_after_bambuddy_restart_does_not_fire_print_start` pins the contract for the reporter's exact scenario — and asserts that `_was_running` still becomes True so completion still fires when the print ends. The `is_file_change` branch was unaffected (it already required `_previous_gcode_file is not None`, so restart-catch-up never reached it anyway).

- **Create User form rejected weak passwords with an opaque "HTTP 422" toast** ([#1303](https://github.com/maziggy/bambuddy/issues/1303), reported by @TrickShotMLG02) — Three independent UX gaps stacked on top of each other. **(1) Discoverability**: the Create User and Edit User modals showed no hint about the backend's password complexity requirements (`min 8 chars` + uppercase + lowercase + digit + special character; enforced in `backend/app/schemas/auth.py:_validate_password_complexity`). Reporter typed an 8-character all-digits password and had no way to know why it failed. **(2) Validation mismatch**: the frontend's pre-submit check at `SettingsPage.tsx` was only `password.length < 6`, accepting passwords the backend would reject — every weak password got bounced after the round-trip instead of getting blocked locally. **(3) Error display fragility**: when the backend returned a 422 with a Pydantic detail array, the API client's error parser at `frontend/src/api/client.ts:107` could fall through to the bare `HTTP ${status}` fallback if the mapped/filtered detail array ended up empty after stripping the `"Value error, "` prefix — masking the real reason as just "HTTP 422". Fixes: (1) added a `passwordRequirements` helper line under both password inputs in Create User / Edit User; (2) extracted `checkPasswordComplexity` into `frontend/src/utils/password.ts`, called from `handleCreateUser` and `handleUpdateUser` before the API request — it returns the same FIRST failing rule the backend's validator would have flagged (uppercase before lowercase before digit before special, matching `_validate_password_complexity`'s order — fixing one rule shouldn't immediately trip a different message), and the submit button is disabled until all rules pass; (3) the API client now falls back to `JSON.stringify(detail)` when the mapped array is empty, so a malformed but non-empty 422 detail surfaces SOMETHING informative instead of a bare status code. New translation keys `settings.passwordRequirements`, `settings.toast.passwordNeeds{Uppercase, Lowercase, Digit, Special}`, plus the existing `passwordTooShort` text updated from "6 characters" to "8 characters". English + German fully translated (German reporter's locale); FR/IT/PT-BR translated using straightforward equivalents; JA/ZH-CN/ZH-TW seeded with English for the new complexity messages (existing project flow for new strings). 7 new unit tests in `frontend/src/__tests__/utils/password.test.ts` pin the validator's contract, including the reporter's exact `"12345678"` input which now produces a local "Password must contain at least one uppercase letter" toast instead of a 422 round-trip.

- **External NAS scan hung forever and never committed subdirectories** ([#1299](https://github.com/maziggy/bambuddy/issues/1299), reported by @joeferrante) — Linking an external mount with ~1200 subdirectories caused the "Link External Folder" modal to spin until the FE gave up, after which the mount appeared in the sidebar but with no subdirectories, and subsequent scans had no effect either. The reporter's support bundle pinpointed two compounding problems. **(1) `TypeError: unsupported operand type(s) for /: 'str' and 'str'` on every STL** — 1,606 instances in the log. `generate_stl_thumbnail` at `stl_thumbnail.py:119` does `thumbnails_dir / thumb_filename`, which requires a `Path`, but the external-scan call site at `library.py:1256` passed both arguments as `str` (`generate_stl_thumbnail(str(filepath), str(thumb_dir))`). Every STL crashed inside the `try/except` and got logged at WARNING level — visible spam but more importantly wasted work (`trimesh.load()` and matplotlib setup ran before the failing division). Fix: defensive `Path()` coerce at the top of `generate_stl_thumbnail` so the function works regardless of how callers pass args. Regression test `test_string_arguments_accepted_without_typeerror` pins the contract. **(2) Scan ran STL thumbnail generation synchronously inside the HTTP request** — even after fix (1), `trimesh.load()` + matplotlib render is 1–5 seconds per STL; on a NAS with thousands of STLs that's hours of work blocking the modal. Frontend would time out, user would refresh, the HTTP request would be cancelled, `db.commit()` at `library.py:1331` would never run, and no folder/file rows would be committed — which is exactly why "subsequent scans have no effect" (each retry started from scratch and hit the same wall). Fix: scan now defers STL thumbnails to a background task. After `db.commit()`, the route spawns `asyncio.create_task(_backfill_external_stl_thumbnails(folder_ids))` with the full set of folder IDs from `folder_cache.values()` (covers both pre-existing subfolders AND the ones created during this scan — `all_folder_ids` is snapshotted before the walk and would have missed the new ones), then returns immediately. The background task opens its own `async_session`, walks every STL file with `thumbnail_path IS NULL` in the linked folder tree, generates each thumbnail, and commits per-file so a server restart mid-run only loses the in-flight thumbnail. Survives FE refresh because the task lives in the FastAPI event loop, not the request scope. The reporter's smaller mount (`/mnt/NAS_3d_files/3mf_Files`, 4 subdirectories) used to work because it completed inside the FE timeout window — with this fix, the 1200-subdir parent mount completes equally fast and thumbnails fill in over the following minutes. **Auto-scan after create unchanged**: `FileManagerPage.tsx:1147-1151` still calls `scanExternalFolder` immediately after `createExternalFolder`, which is correct UX — what changed is that the scan response now arrives in seconds instead of timing out.

- **MakerWorld "Open Cloud settings" link landed on the wrong page** ([#1300](https://github.com/maziggy/bambuddy/issues/1300)) — On the MakerWorld page, the "Open Cloud settings" hyperlink shown in the sign-in-required banner (when no Bambu Cloud token is stored) pointed at `/settings?tab=cloud`. The Settings page has no `cloud` tab (its tabs are general/plugs/notifications/queue/filament/network/apikeys/virtual-printer/spoolbuddy/failure-detection/users/backup), so the URL-param check at `SettingsPage.tsx:179` (`validTabs.includes(tabParam) ? tabParam : 'general'`) silently fell back to the General tab. The Bambu Cloud login UI actually lives on the Profiles page (`/profiles`), which already defaults its sub-tab to `cloud` — the same destination the existing `backup.cloudLoginRequired` i18n string ("Sign in under Profiles → Cloud Profiles…") documents. One-line fix in `MakerworldPage.tsx:438`: `to="/settings?tab=cloud"` → `to="/profiles"`. The Profiles page's `useState<ProfileTab>('cloud')` (line 2822) means no query param is needed — landing on `/profiles` opens the Cloud sub-tab directly.

- **External-spool prints no longer credit usage to AMS slot 0's Spoolman spool** ([#1276](https://github.com/maziggy/bambuddy/issues/1276), reported and diagnosed by @ojimpo — regression of #853) — On a single-filament external-spool print (TPU loaded in `vir_slot id=254` on the reporter's H2S + AMS 2 Pro), `_resolve_global_tray_id` in `spoolman_tracking.py` was crediting the usage to whatever Spoolman spool happened to be linked to AMS slot 0 — a completely unrelated material in the reporter's case. ~48.94 g of TPU was credited to a PLA spool across 4 prints before they noticed. Root cause: BambuStudio encodes virtual tray IDs (254/255) as `-1` in the flat `ams_mapping` array it sends to the printer (a convention already documented in `bambu_mqtt.py:start_print()`), but the spoolman tracking helper was treating `-1` as "unmapped → use position-based default" and the default mapped `slot_id=1` → `global_tray_id=0`. When `slot_to_tray[slot_id-1] == -1` and `ams_trays` contains an external slot (254 or 255), the helper now returns the external tray ID directly, matching the convention `start_print()` uses on the other side of the pipeline. Prefers 254 over 255 (consistent with single-nozzle `tray_now` reporting and the `vir_slot` id=255→254 remap in `bambu_mqtt.py:864`). Legacy behavior preserved when `ams_trays` is empty or contains no external slot (callers that don't pass `ams_trays` keep the position-based fallback). Two regression tests cover the reporter's exact scenario (`ams_trays={0,1,2,3,254}, slot_to_tray=[-1]` → 254) plus the H2D-deputy case and the fall-through-when-no-external case. **Root cause investigation and patch by @ojimpo.**

- **Virtual-printer queue mode now honors workflow default print options** ([#1235](https://github.com/maziggy/bambuddy/issues/1235), reported by @jc21, root cause and patch by @jc21 in #1277) — Prints sent from Bambu Studio (or any slicer) to a VP in `print_queue` mode arrived in the queue with `bed_levelling`, `flow_cali`, `vibration_cali`, `layer_inspect`, and `timelapse` set to the SQLAlchemy column-level defaults, never the user's workflow preferences. The reporter happened to have every workflow default set to the opposite of the column defaults, so prints appeared to have all five options inverted; every queue item required hand-editing before dispatch. The manual `POST /print-queue/` endpoint reads these fields off the request body (the frontend pulls them from settings before submitting), but the VP-FTP-receive path at `backend/app/services/virtual_printer/manager.py:_add_to_print_queue` constructed `PrintQueueItem` without touching them at all — SQLAlchemy then filled in `bed_levelling=True, flow_cali=False, vibration_cali=True, layer_inspect=False, timelapse=False` regardless of what was in the DB. Fix reads `default_bed_levelling` / `default_flow_cali` / `default_vibration_cali` / `default_layer_inspect` / `default_timelapse` via the existing `get_setting()` helper (same pattern already used in the function for `virtual_printer_archive_name_source`) and passes them explicitly to `PrintQueueItem`. A small `_bool_setting()` helper maps `None → AppSettings schema default`, so a fresh install with no workflow page customization behaves identically to before. Regression tests: `test_add_to_print_queue_uses_workflow_defaults_from_settings` (verifies all five settings flow through with values opposite to the column defaults, matching the reporter's exact scenario) and `test_add_to_print_queue_falls_back_to_schema_defaults_when_unset` (verifies the no-DB-row path).

- **Linking a Spoolman spool to an AMS-HT slot no longer fails with a CHECK constraint error** ([#1274](https://github.com/maziggy/bambuddy/issues/1274), reported by guillaume.houba) — On H2C / H2D, AMS-HT units report `ams_id` 128+ (one ams_id per unit, single tray). The `spoolman_slot_assignments` table's `ck_ams_id_range` constraint only allowed 0-7 (standard AMS) or 255 (external), so the upsert on `POST /spoolman/inventory/slot-assignments` blew up with `IntegrityError: CHECK constraint failed: ck_ams_id_range` and the user had no way to link any spool to an AMS-HT slot. Widened the constraint formula to `(ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255` — matches the value range the internal `spool_assignment` table already accepts and leaves room for up to 64 AMS-HT units (the existing `bambu_mqtt`/usage-tracker code uses the same 128-based addressing). Updated in the ORM model (`models/spoolman_slot_assignment.py`) and both the SQLite/Postgres `CREATE TABLE` DDL in `core/database.py`. New idempotent migration `_migrate_widen_spoolman_slot_ams_id_range`: Postgres path runs `DROP CONSTRAINT IF EXISTS` + `ADD CONSTRAINT` (no data risk — the new formula is strictly wider than the old); SQLite path detects the stale formula in `sqlite_master`, table-rebuilds via the standard `_v2` rename pattern used elsewhere in this file (`_migrate_update_auto_link_constraint` at `database.py:418`), and leaves pre-constraint legacy tables untouched. Tests: `test_ams_id_check_admits_ams_ht_range` (ORM + DDL formula) and `test_assign_accepts_ams_ht_id` (end-to-end `POST /slot-assignments` with `ams_id=128`).

- **X2D live camera stream no longer cut by Obico polling / snapshot capture** ([#1271](https://github.com/maziggy/bambuddy/issues/1271), reported by @clabeuhtegrite) — The MJPEG fan-out broadcaster from #1089 lets multiple browser viewers share one upstream RTSP socket per printer, but internal callers (Obico AI polling at the user's configured `obico_poll_interval`, and the manual `/camera/snapshot` endpoint) still opened their own fresh RTSP connections. X1C / H2D / P2S firmware tolerates brief concurrent camera sockets so the gap was invisible there. X2D firmware `01.01.00.00` (and likely future firmwares) enforces strict single-camera-connection more aggressively: every Obico poll (default every 5 s) kicked the live stream, the broadcaster paid the multi-second RTSP handshake to reconnect, and the user saw the stream cut "all the time." New helper `try_get_active_buffered_frame(printer_id)` at [`api/routes/camera.py:74`](backend/app/api/routes/camera.py) returns the broadcaster's last buffered frame (always <1 s old while any viewer is connected) and `None` when no viewer is active. Obico's `_capture_frame` and the `/camera/snapshot` endpoint check it first and only fall through to a fresh socket when no stream is running — preserving today's behavior when nobody is watching. `plate_detection` and `layer_timelapse` deliberately not converted: plate-detection needs guaranteed-fresh frames post-print (false-positive risk if the user already grabbed the print in the same second), and layer-timelapse is for external cameras only. Regression tests: `test_camera_snapshot_reuses_buffered_frame_when_stream_active` and two `TestCaptureFrameSharesBroadcasterUpstream` Obico tests.

- **Usage tracker: spool swaps in UNUSED slots mid-print no longer charge the old spool** ([#1269](https://github.com/maziggy/bambuddy/issues/1269), reported by @maugsburger) — Path 2 of the usage tracker (AMS remain% delta fallback) iterated every AMS tray that had a remain% delta, even slots the print never touched. When a user swapped spools in an unrelated slot during a print, the new spool reports `remain=0` (no RFID tag yet) while the snapshot from print-start was 100%, so the fallback charged the originally-assigned spool the full 1000 g. Reporter's case: single-filament print on AMS0-T3 (`ams_mapping=[3]`), swapped a spool in T1 and another in T2 to refill while the print continued — wound up with `Spool 27 consumed 1000.0g (100%) on printer 1 AMS0-T1` and `Spool 24 consumed 170.0g (17%) on printer 1 AMS0-T2`, neither of which were ever in the print. Fix: the fallback now builds `print_used_keys` from `session.ams_mapping`, `state.tray_change_log`, and `session.tray_now_at_start` (the three runtime signals telling us which trays were actually part of the print), converts each global tray ID to `(ams_id, tray_id)` using the standard convention (254/255 → external, ≥128 → AMS-HT, otherwise `id // 4, id % 4`), and skips fallback for trays whose key is not in that set. When all three signals are empty (legacy edge case: no slicer push, no MQTT tray-change events, no `tray_now` at start) the legacy "scan every tray" behavior is preserved so we don't regress prints with no metadata. Regression test in `test_usage_tracker.py::test_skips_fallback_for_trays_outside_print_mapping` reproduces the reporter's exact scenario.

- **Printer card: smart-plug live wattage now rounded to whole watts** ([#1266](https://github.com/maziggy/bambuddy/issues/1266), reported by @Carter3DP) — The printer card's smart-plug status badge rendered `plugStatus.energy.power` raw, so plugs that report fractional watts (Kauf PLF12 via ESPHome / Home Assistant in the reporter's case, but any MQTT plug pushing a float can hit this) showed values like `14.123456789012` W and overflowed the card width. `SmartPlugCard` and `SwitchbarPopover` already wrapped the same field in `Math.round()`; only the printer-card badge was missing the round. Single-line fix at `frontend/src/pages/PrintersPage.tsx:4569`.

## [0.2.4] - 2026-05-11

### Added
- **Build-plate icon on archive cards + uniform printer/model line** ([#1253](https://github.com/maziggy/bambuddy/issues/1253), reported by @tonygauderman) — Archive cards now show an OrcaSlicer-style bed icon in the printer/model row indicating which build plate the print was sliced for (Cool / Cool SuperTack / Engineering / High Temp / Textured PEI / Smooth PEI), with the full plate name in the hover tooltip. Closes the gap where users had to remember which plate matched a re-print or open the source 3MF in a slicer just to read the bed setting. **Card row also unified:** archives with a real Bambuddy-printer association used to render as `H2D-1 GCODE …` while slicer-only uploads rendered as `Sliced for X1C GCODE …` — same line, two different shapes. Dropped the `Sliced for ` prefix so both render as a uniform `<name-or-model> [bed-icon] GCODE <hash>` row, scanning the same regardless of provenance. **Backend:** new `bed_type` column on `print_archives` (idempotent `ALTER TABLE` migration; SQLite + Postgres safe), populated from `curr_bed_type` in `Metadata/slice_info.config` (per-plate metadata, the authoritative source — that's the bed type that actually got sent to the printer for the exported plate) with a fallback to `Metadata/project_settings.config`'s top-level `curr_bed_type` for older 3MF shapes. Wired through both code paths that produce archive responses: `archive_to_response()` (the hand-rolled dict converter at `archives.py:97` — easy to miss, the schema-only change is silently dropped by Pydantic since the route bypasses `from_attributes`) and the `/rescan` endpoint, so old archives can be re-parsed by the user via the existing per-archive Rescan button. Newly-ingested archives get the value automatically. **Backfill script:** `scripts/backfill_archive_bed_type.py` (with `--dry-run`) re-opens every NULL archive's 3MF on disk and populates the column — opt-in for users who want their entire history covered without waiting for natural turnover. Auto-loads `.env` from project root *before* importing backend modules (since `core/config.py:52` reads `DATABASE_URL` from `os.environ` at import time, not from `pydantic-settings` at `Settings()` time), prints the resolved DB URL with credentials redacted on every run so operators can confirm they're hitting the intended database (Postgres / SQLite — Bambuddy supports both per #1219's `DATABASE_URL` pathway), and calls `init_db()` itself before querying so the migration applies even if the script is run against a database the backend hasn't touched yet. **Frontend:** 6 OrcaSlicer-style PNGs ship in `frontend/public/img/bed/` (under `/img/` because that path was already statically mounted at `main.py:5244`; the `/bed-icons/` toplevel attempted first hit the SPA catch-all and returned `index.html` as `text/html`, which the browser then rendered nothing for). New `utils/bedType.ts` maps slicer strings (case-insensitive) to icon + human-readable label; covers Bambu Studio and OrcaSlicer's diverging spellings for the same physical plate (e.g. `Cool Plate` ↔ `PC Plate`, `Cool Plate (SuperTack)` ↔ `Supertack Plate` ↔ `Bambu Cool Plate SuperTack`). Renders on both card-grid view and list view in `ArchivesPage.tsx`. Unmapped or NULL `bed_type` simply omits the icon, so cards stay clean for archives created before this change. Note on icon mapping: `bed_pei.png` → Textured PEI, `bed_pei_cool.png` → Smooth PEI is a best-guess from the OrcaSlicer asset names — swap the two paths in `bedType.ts` if a future user reports the icons reversed for their plate.

- **Spool labels: new 40×30 mm template, hex colour code, bolder brand line** ([#809](https://github.com/maziggy/bambuddy/issues/809) follow-up, requested by @oliboehm) — Three small enhancements to the spool-label printer rolled into one change. **(1) New `box_40x30` template** — 40×30 mm single label, common DK/Brother roll size. Added to `_SINGLE_LABEL_SIZES_MM` in `backend/app/services/label_renderer.py` and to the request body's `Literal[...]` enum in `backend/app/api/routes/labels.py`; height is ≥ 20 mm so it routes through the existing roomy layout (swatch + QR + full text column). **(2) Colour hex code on every label** — new `_hex_code_label()` helper formats `data.rgba` as `#RRGGBB` (alpha-stripped, uppercased to match the inventory UI's colour-picker convention) and returns `""` for missing/malformed input so the caller skips drawing instead of throwing. Rendered as a small line under the material/subtype line in the roomy layout, and as a third line above the spool ID in the tight (AMS) layout — useful when several near-identical material/colour spools sit next to each other in the AMS or on a shelf. **(3) Brand line bigger + bold** — the brand on every label now renders in `Helvetica-Bold` instead of `Helvetica` regular, with size bumped 5.5pt → 6.5pt on the tight layout and 7pt → 8pt on the roomy layout, so it's the most legible non-ID field at arm's length. **Wiring:** `SpoolLabelTemplate` union in `frontend/src/api/client.ts` extended with `'box_40x30'`; `LabelTemplatePickerModal` gets a new `TEMPLATE_OPTIONS` entry for it; `inventory.labels.templates.box40x30.{label,hint}` keys added across all 8 locales (en + de fully translated, fr/it/ja/pt-BR/zh-CN/zh-TW translated to native, with the existing per-key fallback in the modal as a safety net). The 5-template grid still wraps to 2 columns on small viewports per #1230's fix; modal regression test was widened from `4` to `5` template buttons. **Tests:** `ALL_TEMPLATES` parametrize tuple in `test_label_renderer.py` extended with `box_40x30` so all 7 generic invariants (PDF header, empty-input, multi-colour, missing-fields, malformed-rgba, long strings, sheet pagination) cover the new template; new `test_hex_color_code_rendered_when_rgba_set` (asserts `#F5E6D3` appears in the uncompressed PDF for both 40×30 and 62×29), `test_hex_color_code_skipped_when_rgba_invalid` (regex pin: no `#RRGGBB` shape on the label when rgba is malformed, except the spool ID's `#42`), and `test_brand_rendered_in_bold_per_809_followup` (asserts `Helvetica-Bold` font reference is in the PDF — caught a regression if the brand line ever reverts to regular weight). All 33 backend tests + 15 frontend modal tests pass; ruff clean.

- **Copy spool — duplicate any spool's settings into a fresh inventory row in two clicks** ([#1234](https://github.com/maziggy/bambuddy/issues/1234), [PR #1246](https://github.com/maziggy/bambuddy/pull/1246) by @MiguelAngelLV) — Adds a copy button (`Copy` icon) next to the existing edit button on every spool in the inventory page across all three views (table row, card, grouped table inner row). Clicking it opens the existing `SpoolFormModal` pre-filled with every field from the source spool — material, brand, color, slicer preset, label/core/cost, K-profiles, all of it — except `weight_used` which is reset to 0 (since the new spool starts full) and the RFID identity fields (`tag_uid`, `tray_uuid`, `tag_type`, `data_origin`) which aren't part of the form payload anyway, so the new spool is its own physical roll. Save calls `api.createSpool` (or `api.createSpoolmanInventorySpool` in Spoolman mode — both inherit the dispatch routing for free). Closes the long-running gap where users with many near-identical spools (e.g. five 1 kg PETG-CF rolls bought in a single order) had to re-enter every field from scratch on each one. **Implementation shape:** `SpoolFormModalProps.mode: 'create' | 'edit' | 'copy'` (exported as `SpoolFormMode`) replaces the previous `isEditing = !!spool` heuristic — every existing call site in `InventoryPage.tsx` was updated to pass the explicit mode, and the modal's title / submit-button label / weight-reset gate / submit-route branching all key on `mode` directly. The `onCopy` callback is optional on `SpoolCard`, `SpoolTableRow`, and `SpoolTableGroup` (matches the existing `onPrintLabel?` pattern), so the button is conditionally rendered and other consumers of those subcomponents don't get a copy affordance forced on them. Card-view and table-row buttons stop click propagation so clicking copy doesn't also fire the parent row's edit handler. **Quick Add interaction:** the Quick Add toggle is gated `mode === 'create'` (was `!isEditing`), so it stays out of copy mode — otherwise a user could enable Quick Add and bump quantity to N under the singular "Copy Spool" title and silently bulk-create N copies via `bulkCreateMutation`. **i18n:** new `inventory.copySpool` key across all 8 locales (en + de translated, fr/it/ja/pt-BR/zh-CN/zh-TW seeded with English fallback per project flow). **Tests:** 3 new in `SpoolFormModal.test.tsx` (`SpoolFormModal copy mode` describe block — title shows "Copy Spool", save calls `createSpool` not `updateSpool`, `weight_used` reset to 0 in the create payload when copying a spool with non-zero usage), 2 new in `InventoryPageCopyButton.test.tsx` (table-row copy button click → "Copy Spool" heading, cards-view copy button click → same heading after switching view modes) — guards against the three call sites drifting apart. Existing `SpoolFormBulk.test.tsx` and `SpoolFormModal.test.tsx` renders that omitted the `mode` prop were updated with the explicit `mode="create"` so the tightened Quick Add gate doesn't hide the toggle from them. Both `InventoryPageCopyButton.test.tsx` and `InventoryPageDeepLink.test.tsx` gained MSW handlers for the modal's open-time fetches (`/api/v1/cloud/status`, `/api/v1/cloud/local-presets`, `/api/v1/cloud/builtin-filaments`, `/api/v1/inventory/color-catalog`, `/api/v1/inventory/spool-catalog`, `/api/v1/printers/`) — without them MSW passes through to the real network, ECONNREFUSEs, and the rejected fetch resolves after the test environment is torn down, surfacing as a flaky "window is not defined" unhandled rejection in the modal's `setLoadingCloudPresets(false)` finally block (pre-existing flake hit ~1 in 3 full-suite runs at PR head).

### Fixed
- **`.bbscfg` Printer Preset Bundle import was broken for every user since launch — sidecar compose file pointed at the wrong branch** ([#1312](https://github.com/maziggy/bambuddy/issues/1312), reported by @hasmar04, confirmed by @netscout2001) — `slicer-api/docker-compose.yml`'s `build.context` pointed at `https://github.com/maziggy/orca-slicer-api.git#bambuddy/profile-resolver`, but the `POST /profiles/bundle` endpoint plus the `uploadBundle` multer middleware were only ever committed to a sibling branch `bambuddy/bundle-import` (commit `a3172c5`, 2026-05-06). Every user who ran the documented `docker compose up -d` got a sidecar without the bundle endpoint &mdash; their `POST /profiles/bundle` fell through to the generic `POST /profiles/:category` handler, which either rejected with "Name cannot be empty" (no `name` form field sent) or "Invalid file type. Only JSON files are allowed." (the JSON multer filter rejecting the `.bbscfg`). **Fix:** `bambuddy/bundle-import` fast-forward-merged into `bambuddy/profile-resolver` in the orca-slicer-api repo and pushed, so the compose file's existing branch ref now points at the right commit. No Bambuddy code change. Existing users rebuild with `cd slicer-api/ && docker compose --profile bambu build --no-cache --pull && docker compose --profile bambu up -d` &mdash; `--pull` is the key flag because BuildKit caches the git fetch context separately from layer caches, so `--no-cache` alone silently reuses the old branch checkout. New users on 0.2.5+ are unaffected. Lesson on diagnosis flow: the wrong root cause was reported twice during triage before the actual branch mismatch was caught &mdash; first as "build a week ago, before the bundle endpoint existed" (correct claim for the wrong branch), then as "rebuild with --pull" (still hit the same bug because the compose file pointed at the branch that never got the work). The reporter's third round of logs &mdash; the multer "Only JSON files are allowed" error string from `upload.js:17`, which only matches `uploadJson` not `uploadBundle` &mdash; was the smoking gun that no amount of rebuilding would help because the wired-up branch genuinely lacked the endpoint.

### Changed
- **Support bundle records slicer-API CLI versions; wiki sidecar-update docs hardened** ([#1312](https://github.com/maziggy/bambuddy/issues/1312) follow-up) — Triage scaffolding added during investigation of the bundle-import bug above. Useful independent of that fix: the next time a user reports a sidecar-related failure, the support bundle will identify which slicer CLI version is actually running without needing a manual `curl /health`. **Backend:** new `_fetch_slicer_health(url)` helper in `backend/app/api/routes/support.py` does a 2-second GET on `<sidecar>/health`, parses the JSON, and walks every non-`dataPath` key under `checks` looking for a `version` field &mdash; needed because the wrapper labels both bambu-studio-api and orca-slicer-api as `checks.orcaslicer` regardless of which CLI is actually bundled (cosmetic wrapper bug, not Bambuddy's). `_collect_slicer_api_info` now calls it instead of the bare reachability ping and adds two new fields per side to the integrations block: `bambu_studio_version`, `orcaslicer_version`. Captures `"unknown"` verbatim when the wrapper's `--help` regex didn't match (which is itself diagnostic). Behavior preserved on error paths: empty URL returns `None`, connection failure returns `{reachable: False, version: None}`, malformed/non-200 returns `{reachable: True, version: None}` so the reviewer can separate network failure from misconfiguration. Trailing-slash in the configured URL is stripped before appending `/health`. **Tests:** 9 new in `TestFetchSlicerHealth`; existing `TestCollectSlicerApiInfo` tests updated to patch `_fetch_slicer_health` and assert the new `_version` fields. All 62 helper tests pass; ruff clean. **Docs:** `bambuddy-wiki/docs/features/slicer-api.md` got four additions. (1) Quick Start gains a warning callout that the Compose file builds from a branch tip and a plain `docker compose up -d` will keep using the originally-built image. (2) The Updating section now recommends `docker compose --profile bambu build --no-cache --pull` (both flags) and explains why both matter. (3) New troubleshooting entry for the "Name cannot be empty" / "Only JSON files are allowed" `.bbscfg` import error. (4) New troubleshooting entry for the orphan-container conflict (`container name "/bambu-studio-api" is already in use`) that hits users whose existing containers were built from an older compose file with un-prefixed image tags. The pre-existing `/health version: "unknown"` entry also got a note clarifying that the wrapper mislabels the `checks` field as `orcaslicer` for both sidecars &mdash; both are cosmetic, not stale-image indicators.

### Fixed
- **LDAP settings: "Advanced" collapsible section header was always rendering in English regardless of UI language** ([#1297](https://github.com/maziggy/bambuddy/issues/1297), reported by @Fuechslein) — `LDAPSettings.tsx:352` calls `t('settings.ldap.advanced') || 'Advanced'`, but the translation key was never defined in any locale file. The `|| 'Advanced'` fallback kicked in and the header rendered as English in every language. Added `settings.ldap.advanced` to all 8 locales: `Advanced` (en), `Erweitert` (de), `Avancé` (fr), `Avanzate` (it), `詳細設定` (ja), `Avançado` (pt-BR), `高级` (zh-CN), `進階` (zh-TW). No component change needed — the fallback now never triggers because the key resolves properly. i18n parity check holds at 4754 leaves across all locales.

- **Clear Plate button required granting Settings > Read Settings, leaking the entire Settings UI to non-admin users** ([#1293](https://github.com/maziggy/bambuddy/issues/1293), reported by @Tivonfeng) — On the Printers page, the "Clear Plate" button is gated on the global `require_plate_clear` setting being `true`. The page reads that value from `GET /api/v1/settings`, which requires `Permission.SETTINGS_READ`. A user with `printers:clear_plate` but no `settings:read` got a 403 on the settings fetch, the frontend's `settings` query stayed undefined, `requirePlateClear` evaluated to `false`, and the button never rendered. The reporter's workaround — also grant `settings:read` — works but also adds the Settings nav item to the sidebar and grants visibility of SMTP/LDAP/MQTT credentials and every other setting in the DB, which is exactly the leak they were trying to avoid. **Fix:** new `GET /api/v1/settings/ui-preferences` endpoint that returns a curated dict of UI rendering fields without requiring SETTINGS_READ — matches the existing `GET /settings/default-sidebar-order` precedent (intentionally unauthenticated for the same reason — UI rendering needs values that aren't admin-gated). Exposed fields are explicitly opt-in via a `_UI_PREFERENCE_FIELDS` tuple in `routes/settings.py`: `require_plate_clear`, `check_printer_firmware`, `camera_view_mode`, `time_format`, `date_format`, `drying_presets`, `ams_humidity_good`, `ams_humidity_fair`, `ams_temp_good`, `ams_temp_fair`, `bed_cooled_threshold`. Anything not on that list — including every sensitive field — is never returned, no matter what's in the DB. PrintersPage now fetches from `/settings/ui-preferences` via a new `api.getUiPreferences()` client method; the cache key changed from `['settings']` to `['ui-preferences']` so it doesn't collide with the admin-gated full settings query other admin pages still use. As a side-effect, the page's 4 other settings-driven UI features (drying presets, camera view mode, time format display, firmware-check banner) also stop silently degrading for non-admin users — they all live on the same fetch. Regression tests in `backend/tests/integration/test_settings_ui_preferences.py` pin: endpoint returns 200 without SETTINGS_READ, response includes `require_plate_clear` as a bool, field set exactly matches `_UI_PREFERENCE_FIELDS` (so accidentally adding a sensitive field there fails the test), and a "secret canary" test that seeds 23 sensitive keys with recognizable values and asserts none of them appear in either the response keys or the response body. Frontend types in `client.ts` tighten `camera_view_mode` and `time_format` to the same literal unions as `AppSettings` so the new endpoint slots into PrinterCard's prop types without casts.

- **LDAP user logins wiped manually-assigned BamBuddy groups** ([#1292](https://github.com/maziggy/bambuddy/issues/1292), reported by @Fuechslein) — When an admin assigned an LDAP-authenticated user a BamBuddy group that wasn't mapped from LDAP (e.g. "Administrators" while the LDAP mapping only covered "Users"), the assignment vanished on the user's next login. The reporter's observation matched the code exactly: assigning a group while the user was logged in held until the next login because `user.groups` was just mutated in memory; on next login, `_sync_ldap_user` in `backend/app/api/routes/auth.py:1187` rebuilt `user.groups` from LDAP state alone and blew away the manual assignment. The design intent (LDAP truth must propagate, including revocation) was correct, but the implementation was over-broad — every BamBuddy group got wiped, not just LDAP-mapped ones. **Fix:** `_sync_ldap_user` now computes the set of "LDAP-managed" BamBuddy group names = values of `ldap_group_mapping` ∪ `{ldap_default_group}`. Groups inside that set are still rebuilt from LDAP truth on each login (so revocation works). Groups outside that set are treated as manual admin assignments and preserved. The partition happens via a list comprehension over `user.groups`; no schema or DDL change. Edge case explicitly tested: a manual assignment to a group that IS in the LDAP mapping is still overridden by LDAP state — once an assignment is in the user_groups table you can't tell manual-but-mapped from LDAP-derived, so LDAP wins for any group it has authority over. Regression tests in `backend/tests/integration/test_ldap_group_sync.py` cover: manual group survives login (the reporter's exact scenario), revocation still propagates for LDAP-managed groups, default_group persists across empty-LDAP logins, manual assignment to a managed group is overridden, and the realistic mixed case where a user has multiple manual + multiple LDAP groups at once.

- **Internal inventory: `storage_location` field was silently dropped on save and never shown in the table** ([#1291](https://github.com/maziggy/bambuddy/issues/1291), reported by @needo37) — The `storage_location` column existed on the Spool ORM model (`backend/app/models/spool.py:57`) but was missing from the Pydantic schemas in `backend/app/schemas/spool.py` (`SpoolBase`, `SpoolUpdate`, and by extension `SpoolResponse`). Pydantic silently strips unknown fields, so PATCH writes to `/inventory/spools/{id}` reached the update route's `model_dump(exclude_unset=True)` already missing the field, the `setattr` loop never touched the DB column, and GET responses left it out — the inventory table always showed "—" in the Storage Location column even when the user had typed and saved a value. Only the internal inventory was affected; Spoolman mode worked because it goes through a separate proxy backend with its own schema. Fix is two added fields in `schemas/spool.py`: one on `SpoolBase` (covers `SpoolCreate` + `SpoolResponse` via inheritance) and one on `SpoolUpdate` (standalone). Both constrained to `max_length=255` to match the DB column's `String(255)`. No route changes needed — the update handler at `inventory.py:961` already uses the generic dump-then-setattr pattern that picks up any new schema field automatically. Note on UX intent: `storage_location` is the user-defined free-text label ("Drybox #1", "Top shelf"), distinct from `location` which is the AMS slot assignment ("AMS-A slot 3") — keeping both is the right call. Regression tests in `test_spool_schemas_storage_location.py` lock in: create/update accept the field, the response surfaces it, explicit-null clears via `exclude_unset` round-trip, omitted-on-PATCH is left untouched (doesn't accidentally clear), and `max_length=255` is enforced (so the API returns a clean 422 instead of a SQLAlchemy column-length error).

- **Archives page didn't auto-refresh when a slicer sent a print to a Virtual Printer — the new card only appeared after switching tabs** ([#1282](https://github.com/maziggy/bambuddy/issues/1282), reported by @kleinwareio) — Real-printer prints broadcast `archive_created` over the WebSocket from `main.py`'s MQTT `print_start` handler, and the Archives page listens for that event in `frontend/src/hooks/useWebSocket.ts:241` to invalidate its react-query cache. The VP file-receive paths in `backend/app/services/virtual_printer/manager.py` (`_archive_file` for immediate mode and `_add_to_print_queue` for queue mode) created the archive and committed it to the DB but never broadcast the event — so the page stayed stale until the user clicked another tab and back, which triggered a refetch on focus. **Fix:** factored a small `_broadcast_archive_created(archive)` helper onto `VirtualPrinterInstance` that imports `ws_manager` lazily (matches the file's existing late-import convention for archive/queue imports) and emits the same `{id, printer_id, filename, print_name, status}` payload shape `main.py` uses. Called from both VP paths immediately after the archive is logged (`_archive_file`) and after the queue item is committed (`_add_to_print_queue`). Broadcast failures are swallowed at debug level so a transient WebSocket issue can't break the file-receive flow. The review mode path (`_queue_file`) is intentionally untouched — it creates a `PendingUpload`, not a `PrintArchive`, and renders on a different page. **Tests:** `test_archive_file_broadcasts_archive_created` and `test_add_to_print_queue_broadcasts_archive_created` patch `ws_manager.send_archive_created` and assert it's called once with the right payload shape. **Affects:** every Bambuddy install using a VP in `immediate` or `print_queue` mode; review mode and proxy mode are unaffected.

- **Virtual Printer wedged the slicer at "Downloading...(0%)" when a user clicked Print (instead of Send) against a non-proxy-mode VP, and blocked the next dispatch with "The printer is busy with another print job"** ([#1280](https://github.com/maziggy/bambuddy/issues/1280), reported by @kleinwareio) — Bambuddy's VP supports two distinct dispatch flows from the slicer: **Send** (file upload only — the path queue / immediate / review modes are designed for) and **Print** (file upload + start-print, intended for proxy mode where there's a real printer behind the VP). The reporter's setup was queue mode but they clicked Print, which is unsupported there. The user-facing symptom was wedging instead of a clean error: the FTP upload completed, the file landed in Bambuddy's queue, but Orca's UI froze at `Downloading...(0%)` and the next attempt was blocked. **Cause:** the VP's simulated state machine, in `backend/app/services/virtual_printer/manager.py::on_file_received`, jumped `PREPARE → IDLE` directly after the FTP upload completed. The Send flow doesn't watch the post-upload state, so Send users never noticed. The Print flow watches the gcode_state cycle expecting `PREPARE → RUNNING → FINISH` and only releases its in-flight-job lock when it sees `FINISH` (or `FAILED`). Going `PREPARE → IDLE` looks to the Print-flow slicer like "printer abandoned my job without confirming completion" → UI keeps the prior job pinned → next dispatch is blocked. `gcode_file_prepare_percent` also stayed at `"0"` for the whole upload window, which is why Orca's "Downloading X%" progress bar never advanced. **Fix:** `on_file_received` now transitions `PREPARE → FINISH` with `prepare_percent="100"` and the just-completed filename. The VP's 1-Hz periodic status push (`mqtt_server.py:363`) broadcasts the new state to every connected slicer within a second, so Orca clears its lock and the next dispatch goes through. The transition is gated to `.3mf` uploads only — auxiliary uploads (printer-side `.gcode` blobs etc.) leave the visible state alone. Treats Print and Send identically in non-proxy modes — Print is now silently handled as "file received, treat as completed" instead of wedging the slicer. Send remains a no-op behavior change because Send doesn't watch the post-upload state. **Tests:** 2 new tests in `backend/tests/unit/services/test_virtual_printer.py` pin (1) the FINISH transition with the correct filename + prepare_percent="100", and (2) the non-3MF guard. Affects every VP mode that isn't proxy (`immediate`, `print_queue`, `review`) on every slicer using the Print flow (BambuStudio + OrcaSlicer in LAN-mode).

- **External-spool filament selection silently rolled back: every "Generic PLA" / preset change for the external slot looked applied in the UI but failed on the printer, and the next print threw "no mapping"** ([#1279](https://github.com/maziggy/bambuddy/issues/1279), reported by @kleinwareio) — Repro: P1S, no AMS, vt_tray active. User picks any filament for the external slot via Bambuddy. The UI looked normal, but the printer's MQTT response was `{"command":"ams_filament_setting", "result":"fail", "reason":"error string"}`. The companion `extrusion_cali_sel` command succeeded, so the K-profile stuck but the filament *identity* didn't — and the next print therefore had nothing to map to. **Cause:** `backend/app/services/bambu_mqtt.py::ams_set_filament_setting` encoded the single-external-spool case as `{ams_id: 255, tray_id: 0, slot_id: 0}`. The "LOCAL `tray_id = 0`" comment in the code was a misread of the printer's *response* shape (the printer echoes `tray_id: 0` as the slot-within-virtual-unit, not the slot index used in the *request*). **Verification:** captured BambuStudio → X1C `ams_filament_setting` publish via `mosquitto`-compatible paho-mqtt subscriber on the same broker, BambuStudio set the external slot to a PLA preset, the published REQ was `{ams_id: 255, tray_id: 254, slot_id: 0, tray_info_idx: "P4d64437", tray_color: "F72323FF", tray_type: "PLA", ...}` and the printer's REP returned `result: "success"`. The on-wire convention for `ams_filament_setting` on the external spool is therefore the *global* tray index (`tray_id: 254`), not a local slot number (`tray_id: 0`). **Fix:** `mqtt_tray_id = 254` for the single-external branch in both `ams_set_filament_setting` and `reset_ams_slot` (which shares the convention). The dual-external branch (H2D, `len(vt_tray) > 1`) was **not** in the captured exchange and is left at `mqtt_tray_id = 0` until a Studio → H2D capture confirms the correct value — a regression test pins the current dual-external encoding so any future change to that branch surfaces immediately. **Affected printers:** every printer whose MQTT push reports `vt_tray` as a single-element list — i.e. one external slot. That covers all single-nozzle Bambu printers (P1P, P1S, A1, A1 mini, X1C, X1E) plus dual-nozzle models that use a single external feed (X2D). **Not affected** by this change: H2D / H2C / H2S, which expose two external slots and go through a separate `len(vt_tray) > 1` branch. That branch is preserved at its existing `mqtt_tray_id = 0` encoding because the captured exchange did not cover it; if the same misencoding turns out to affect dual-external too, a Studio → H2D capture will surface the right values and a follow-up patch will land. **Known asymmetry not touched in this PR:** the inline `ams_filament_setting` built by `_probe_developer_mode` (`bambu_mqtt.py:2971-2985`) still hardcodes `tray_id=0`. The probe is robust to this — its detection logic only matches `reason: "verify failed"` so it correctly identifies dev-mode regardless of whether the command itself succeeds — but the two builders should be unified in a follow-up. **Tests:** 5 new tests in `backend/tests/unit/services/test_bambu_mqtt.py::TestAmsFilamentSettingExternalSpoolEncoding` pin the X1C/P1S/A1 single-external fix, `reset_ams_slot` symmetry, regular AMS slot encoding unchanged, AMS-HT slot encoding unchanged, and the explicitly-unverified dual-external encoding (so any future change to the dual branch surfaces in diff review).

- **Scan For Timelapse matched the wrong video when an older print's filename happened to land near a later archive's completion** ([#1278](https://github.com/maziggy/bambuddy/issues/1278), reported by @1000Delta) — Repro: P2S in LAN-Only mode (no NTP, so printer clock is drifted +8h from UTC), two prints on the same day. Archive 1 correctly attached `video_2026-05-08_09-41-29.mp4`. Archive 2 (started at 16:39:09 UTC, expected `video_2026-05-09_00-42-42.mp4`) reused Archive 1's video with a misleading `diff: 0:02:19`. **Cause:** `scan_timelapse`'s Strategy 2 matcher in `backend/app/api/routes/archives.py` had two compounding flaws. (1) It compared the filename timestamp against both `archive.started_at` **and** `archive.completed_at` with a 48 h tolerance — but the filename always represents the print's START time, never its end, so the end-time branch was a semantic mistake whose only effect was creating false positives. For Archive 2, the stale filename `09:41:29` shifted by hypothesis offset `-8h` → `17:41:29`, which happened to fall ~2 minutes before Archive 2's completion → "diff" 2m19s won. (2) The matcher tried seven hypothesised offsets `[0, ±1, ±7, ±8]`, which densely covers a wide span of the day. Even with the end-time branch removed, the wrong video at offset `-7` lands at `16:41:29` → 2m20s from Archive 2's start, beating the correct video's 3m33s at offset `+8`. **Fix:** extracted Strategy 2 into a pure `_match_timelapse_by_timestamp(video_files, archive_start)` helper that (a) only compares against print **start** time (end-time evidence is handled separately by Strategy 3 via file mtime, which actually does reflect when writing finished), and (b) requires the best (video, offset) pair to beat the next-best pair from a *different* video by at least 15 minutes. When the top two candidates from different videos are too close to call, the helper returns `None` so the route surfaces the existing `available_files` list and the frontend's manual-selection dialog kicks in — which is the fallback the reporter explicitly asked for ("at a minimum, we should support that can fall back to letting the user manually select"). Wide offset support is preserved so EU / JST / AEST users (offsets +1, +7, +9, +10, etc.) still get auto-match when there's no ambiguity. **Tests:** 17 new tests in `backend/tests/unit/test_timelapse_match.py` pin the bug case (`test_issue_1278_archive2_refuses_to_auto_pick_ambiguous`, `test_issue_1278_archive1_still_matches_unambiguously`), the resolution path once the stale video is cleaned up (`test_archive2_resolves_when_stale_video_removed`), each of the 7 supported offsets via parametrize, and the supporting invariants (no `started_at` → `None`, non-timestamp filenames are skipped, same-video different-offset is not ambiguous, well-separated different videos still auto-pick). **Known UX gap not in this PR:** if the matcher auto-picks a wrong match, the user must delete the attached timelapse first before re-scanning — `scan_timelapse` short-circuits with `status: "exists"` when `timelapse_path` is already set. Adding a force-rescan or "wrong match, pick from candidates" affordance is a separate change.

- **Docker image: pip upgraded to >=26.1 to close CVE-2026-6357 (medium)** — The `python:3.13-slim-trixie` base image ships pip 26.0.1, which runs its self-update check *after* installing wheels. A hostile wheel that included a module named like a deferred stdlib import (`urllib`, `ssl`, …) could therefore hijack imports inside the just-finished install step. The exploit path is theoretical for Bambuddy itself — we don't install user-supplied wheels at runtime — but the vulnerable pip version still ships inside the image, GitHub code-scanning flagged it (alert #778), and any downstream user who `pip install`s into the running container inherits the issue. **Fix:** Dockerfile now runs `pip install --upgrade 'pip>=26.1'` immediately before `pip install -r requirements.txt`, so the requirements install itself happens under the patched pip and the resulting `pip-*.dist-info/METADATA` Trivy reads from the layer is the fixed version. No `requirements.txt` change — the floor is enforced at the image-build layer where the vulnerable copy lived. (libexpat1 alert #795 also flagged by code-scanning is a DoS-only XML attribute-collision CVE with no patched Debian trixie package yet — left open as a tracking signal; next base-image rebuild after trixie ships libexpat 2.8.1 will close it automatically.)

- **Gitea backups silently failed after the first run; Forgejo v15 token-scope quirk broke "Test Connection"; many failure paths surfaced cryptic one-word errors** ([#1224](https://github.com/maziggy/bambuddy/issues/1224) reported by @rtadams89, [#1239](https://github.com/maziggy/bambuddy/issues/1239) + [PR #1255](https://github.com/maziggy/bambuddy/pull/1255) by @BurntOutHylian) — Two intertwined problem clusters on the Git-backup path, fixed as one PR. **(1) Gitea backups quietly stopped after run #1.** The Git backup service used GitHub's Git Data API (`POST /git/blobs` → `/trees` → `/commits` → `PATCH /refs`) for every push. Gitea does not implement these write endpoints on modern versions, so every blob POST returned 404; the loop's `continue`-on-non-201 pattern left the change list empty and the route returned `{"status": "skipped"}` instead of committing — no toast, no log row, just "no changes" forever. The first run only worked because the empty-repo path already used the Contents API. **Fix:** `GiteaBackend.push_files` is overridden to use `POST /repos/{owner}/{repo}/contents` with a `files` array — every changed file is sent as `operation: "update"` (with its current blob SHA) or `operation: "create"`, the whole batch commits in a single round-trip, no partial-commit failure mode possible. `_create_branch_and_push` switched from the unimplemented `POST /git/refs` to `POST /branches` with `{new_branch_name, old_ref_name}`. **(2) Forgejo v15+ returns 404 (not 403) for private repos when the token lacks repository scope**, indistinguishable on the wire from "repo not found / token typo" — Test Connection's existing 404 branch said "Repository not found", which sent users chasing the wrong cause. **Fix:** new `ForgejoBackend` (inherits `GiteaBackend`) overrides `test_connection` to GET `/user` first; 401 = bad token, 403 = zero-scope token ("read:user scope missing"), 404 on the subsequent `/repos/` call surfaces the v15-specific "private repo with scope mismatch" hint instead of the generic message. **Hardening pass on the broader backup stack** (B18–B26 review round): every `response.json()[...]` indexing in `github.py` (9 sites: ref/commit/blob/tree/commit/ref across `push_files` + `_create_branch_and_push` + `_create_initial_commit`) now routes through a new `base.py::_read_sha(response, *path)` helper that returns `(sha, error_reason)` — a malformed body no longer bubbles `KeyError('object')` through the catch-all to surface as the cryptic one-word string `"'object'"` in `last_backup_message`. Tree-fetch failures (GitHub side, mirroring the Gitea side) now return `failed` with status code + truncated body instead of letting `existing_files` silently stay empty (which forced every file to re-upload and produced a downstream 422 with no hint at the real cause). GitHub's `_create_branch_and_push` failure message includes the HTTP status code (an empty-body 422 now produces a diagnostic message instead of `"Failed to create branch: "`). Both backends detect `truncated: true` on the tree-listing response (GitHub's tree API truncates at >7MB / >100k entries) and fail loudly asking the operator to rotate the backup repo — previously a truncated listing made the SHA-equality dedup miss and silently re-uploaded every file each run. `test_connection` failure messages now include `str(e)[:200]` alongside the exception class name, so the UI surfaces `"Connection failed: ConnectError: certificate verify failed: hostname mismatch"` instead of just `"ConnectError"`. Gitea's 409-on-`/contents` message was softened from "stale blob SHAs" (one possible cause) to "the branch likely advanced concurrently (web-UI edit, another backup run, or path-vs-tree collision)". Every status-code branch in `github.py` and `gitea.py` mid-push now emits a `logger.warning` with owner/repo context (previously only the outer `except` logged, so a 403/404/422 left a DB row with no application-log entry). Recursive `push_files` re-entry after branch create now logs `"Re-entering push_files after branch create owner/repo -> branch"` at info level so replication-lag second-pass failures are debuggable. **Tests:** +17 new unit tests in `test_git_providers.py` covering the GitHub robustness paths (tree-fetch failure, truncated tree, malformed JSON for ref/commit/blob, 403/422 on `_create_branch_and_push`), the Gitea round-2 hardening (truncated tree, status code in `get_current_commit` / `extract_tree_SHA` / `get_repo_info` failures, log marker emission), and the Forgejo connection-failure detail. Existing 86 → 103 tests, all pass; full backend suite + integration backup tests green; ruff clean. Tested by @BurntOutHylian against Gitea 1.24.7 / 1.25.4 / 1.26.1 and Forgejo v11 / v15 LTS. Companion wiki update at [maziggy/bambuddy-wiki#28](https://github.com/maziggy/bambuddy-wiki/pull/28).

- **Printer card's "Show on Printer Card" smart-plug button toggled power without confirmation** ([#1260](https://github.com/maziggy/bambuddy/issues/1260), reported by @thkl) — Smart plugs with the "Show on Printer Card" option enabled appear as a clickable chip in the printer card's HA-entities row (below the main Smart Plug controls). One click cut power to the printer instantly — including mid-print — even though the main Off button next to it already routes through a `ConfirmModal` and shows an additional running-print warning. **Fix:** the HA-row click handler in `frontend/src/pages/PrintersPage.tsx` now branches on entity type — `script.*` entities keep firing instantly (a script is a fire-once trigger, not a power switch, and the existing semantic of "Run" matches user expectation), but switch/light/anything-else entities now open a new `ConfirmModal` first. The modal reuses the same `variant="danger"` + running-print warning shape as the existing power-off confirmation: when `status?.state === 'RUNNING'` it shows the "WARNING: <printer> is currently printing! Toggling <entity> may cut power and interrupt the print" copy, and renders the default-variant "Toggle the Home Assistant entity <entity>?" message otherwise. The entity name comes from `ha_entity_id` (with `name` fallback) so the modal disambiguates which of multiple plugs the click was on. **i18n:** new `printers.confirm.{haToggleTitle, haToggleMessage, haToggleWarning, haToggleButton}` keys added across all 8 locales (en + de + fr + it + ja + pt-BR + zh-CN + zh-TW translated to native, no English-fallback seeding). Full PrintersPage frontend suite (49 tests) still passes; build clean.

- **X2D / H2D dual-nozzle without AMS: filament mapping reported "Required filament type not found in printer" even when the spools were physically loaded** ([#1257](https://github.com/maziggy/bambuddy/issues/1257)) — Repro: X2D with 0 AMS units, two external spools (Ext-L feeding left extruder, Ext-R feeding right), print job specifies `nozzle_id` per filament. The Schedule Print modal showed the orange "Filament Mapping (Type not found)" header and a forced manual slot picker, even though the matching PETG was sitting right there in the external spool holder. **Cause:** `frontend/src/hooks/useFilamentMapping.ts:18-19` derived dual-nozzle status solely from `printerStatus.ams_extruder_map` being non-empty. That map is populated from AMS units' info bits, so a dual-nozzle printer with zero AMS units gets an empty map → `hasDualNozzle = false` → external spools' `extruderId` falls through to `undefined` (line 64 ternary fallback). The downstream nozzle-aware filter at lines 117 / 377 (`available.filter((f) => f.extruderId === req.nozzle_id)`) then rejected every loaded filament because `undefined !== 0/1` for any non-null `nozzle_id`. The PETG was loaded, just incorrectly stripped from the candidate set during matching. **Fix:** widen the dual-nozzle inference to three independent signals OR'd together: (1) `nozzles[1].nozzle_diameter` populated — the most direct signal, set by `bambu_mqtt.py:2619-2621` only when the printer reports a `right_nozzle_diameter` MQTT field, so a populated value always implies real second-nozzle hardware; (2) `ams_extruder_map` non-empty — preserved as fallback for the dual-nozzle-with-AMS case the original code already handled; (3) `vt_tray.length > 1` — single-nozzle printers (P1S / A1 / X1C) only have one external feed, so multiple external trays only exist on dual-nozzle hardware. The first signal alone is *not* sufficient because the backend `state.nozzles` defaults to a 2-entry list with empty `NozzleInfo()` stubs (`bambu_mqtt.py:160`) on every printer, single-nozzle included — `nozzles.length` would always be 2 on the wire and would have regressed every single-nozzle install. Affects all dual-nozzle printers running without AMS: X2D, H2D, X2 Pro. **Tests:** two new regressions in `src/__tests__/hooks/useFilamentMapping.test.ts`. `matches external spools per-extruder on dual-nozzle without AMS` pins the bug fix — asserts each external spool gets the correct `extruderId` (1 for Ext-L id=254, 0 for Ext-R id=255) and `computeAmsMapping` picks Ext-L for a left-nozzle requirement. `does not fabricate extruderId for single-nozzle with stub nozzles[1]` is the matching guard — asserts that a P1S / A1 / X1C-shape PrinterStatus (with the default-stub second nozzle entry the backend always emits) does NOT trip the dual-nozzle inference, so single-nozzle external spools keep `extruderId=undefined` exactly as they did pre-fix. Together they pin both directions: a future change that re-breaks the X2D path fails CI, and one that mistakenly turns single-nozzle printers into dual-nozzle also fails CI. Full frontend suite (1891 tests across 138 files) green.

- **GCode Viewer had no in-app way to navigate back — the only exit was the browser's back button** — Opening the GCode Viewer from a File Manager card or an Archive card calls `navigate('/gcode-viewer?archive=…' | '?library_file=…')`, which mounts `GCodeViewerPage` as a full-height iframe inside the Layout shell. The page rendered nothing but the iframe, so once the third-party viewer's UI took over the content area there was no in-app affordance to return to the originating list — only the browser's back button. Reported by @maziggy. **Fix:** added a thin back bar above the iframe in `frontend/src/pages/GCodeViewerPage.tsx` with an `ArrowLeft` icon button. The button label adapts to the entry point — `Back to Print Archives` when the URL carries `?archive=`, `Back to File Manager` when it carries `?library_file=`, generic `Back` otherwise (covers the rare deep-link / shared-URL case). Click prefers `navigate(-1)` so the user lands back in their original list with scroll position and filters preserved; falls back to `/archives` or `/files` when the page was opened in a fresh tab and there's no SPA history to return to. Iframe height is now `flex: 1` inside a flex column under the bar instead of a hard-coded `calc(100vh - 3.5rem)` — the layout's existing fixed-header offset is unchanged, only the back bar (~36 px) is subtracted from the viewer's vertical real estate. **i18n:** new `gcodeViewer.{back,backToArchives,backToFiles}` namespace added to all 8 locales (en + de fully translated, fr/it/ja/pt-BR/zh-CN/zh-TW translated to native using each locale's existing page-title vocabulary — `Druckarchiv`/`Dateimanager`, `Archives d'impression`/`Gestionnaire de fichiers`, `Archivi di stampa`/`Gestore file`, `印刷アーカイブ`/`ファイル管理`, `Arquivos de impressão`/`Gerenciador de arquivos`, `打印归档`/`文件管理器`, `列印歸檔`/`檔案管理器`).
- **Archives card's "Reprint" / "Schedule" / "Slice" button labels truncated to "Re..." / "Sc..." on narrow browser windows** ([#1249](https://github.com/maziggy/bambuddy/issues/1249)) — The action row on each archive card has six buttons: two labelled (Reprint + Schedule, or Slice when the file isn't sliced yet) plus four icon-only utilities (open in slicer, external link, globe, download, trash). The labelled buttons used `flex-1` to share whatever space remained after the four fixed-width icon buttons, with the label rendered as `<span className="hidden sm:inline truncate">...</span>` — i.e. visible at any viewport ≥ 640px, with `truncate` ellipsizing when there isn't room. **The Tailwind viewport breakpoint can't see the card width.** The page's grid grows column count alongside viewport (`md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4`), so cards stay roughly 320–380 px wide across breakpoints and the leftover ~30 px in each labelled button isn't enough for "Reprint", which lands on screen as "Re..." — repro'd from a small browser window in the reporter's case. **Fix:** breakpoint bumped from `hidden sm:inline` → `hidden xl:inline` on all three labelled buttons (Reprint at line 1106, Schedule at line 1117, Slice at line 1153 of `frontend/src/pages/ArchivesPage.tsx`). Labels now appear only at viewport ≥ 1280px where the cards (3-4 columns of ~320 px) actually have headroom for them; on narrow windows the buttons render icon-only with their existing `title=` tooltip kept intact for hover and assistive-tech disclosure. Trade-off accepted: a wide-viewport-with-wide-sidebar setup that compresses the card to under ~320px will still see the truncation, but that's a corner case — the common "small browser window" path is fixed without restructuring the row.
- **Spool form's "Slicer Preset" dropdown silently dropped Local Profiles when Bambu Cloud was connected, and collapsed per-printer/per-nozzle variants of cloud and local presets into a single entry** ([#1248](https://github.com/maziggy/bambuddy/issues/1248), reported by @andretietz) — Two distinct defects in the same code path. **Defect 1 (the reported bug):** `buildFilamentOptions` in `frontend/src/components/spool-form/utils.ts` was precedence-based — `if (cloudPresets.length > 0)` returned the cloud list and never reached the local-presets branch, so any Local Profile imported via Profiles → Local Profiles was silently invisible whenever the user was logged into Bambu Cloud (the same profile rendered fine with a green `Local` badge in the AMS Slot configuration modal). The wiki documents the dropdown as "merged and deduplicated" across cloud + local + built-in. **Defect 2 (surfaced during fix verification):** the spool form was collapsing all `@Bambu Lab P1S 0.4 nozzle` / `@Bambu Lab X1C 0.4 nozzle` / `@Bambu Lab A1 0.4 nozzle` variants of "Bambu PLA Basic" into a single dropdown entry by stripping the `@printer` suffix and dedup'ing by base name (one Map.set per family for cloud defaults, one per family for local presets). The AMS Slot modal lists each variant individually and filters by the active printer model, so the user observed strictly more entries in the AMS Slot than in the Add Spool modal even after the merge fix. The right semantic for the spool form — printer-agnostic by design, since a spool isn't bound to a printer — is to show every variant as its own row, exactly as if you'd summed the AMS Slot's per-printer-filtered output across all printers. **Fix:** rewrote `buildFilamentOptions` to (a) actually merge all three sources, dropping the precedence early-return, and (b) push each cloud `setting_id` and each `LocalPreset` row as its own `FilamentOption` instead of collapsing by `name.replace(/@.*$/, '')`. `displayName` now keeps the full `@printer 0.4 nozzle` suffix so users can pick the right variant. Built-in dedup against cloud setting_id is preserved (mirrors `ConfigureAmsSlotModal.tsx:498` exactly). Wired `api.getBuiltinFilaments()` into both callers — `SpoolFormModal` and `SpoolBuddyWriteTagPage`. **Persistence safety:** the saved `slicer_filament` shape is unchanged — cloud picks still persist their `setting_id`, local picks still persist `preset.filament_type || String(preset.id)` (consumed by `backend/app/utils/filament_ids.py::normalize_slicer_filament` which expects `GFL05`/`GFSL05` shapes; persisting the bare LocalPreset row id would break slicing). Local-preset `allCodes` now carries both the `filament_type` form and the `String(preset.id)` form so `findPresetOption` resolves both old (pre-fix) and new picks. **React-key collision:** with collapse removed, two LocalPreset rows can share the same `code` if they share `filament_type`; the dropdown key in `FilamentSection.tsx` is now composed `${option.code}::${option.name}` to stay unique. **Tests:** new `frontend/src/__tests__/components/spool-form/buildFilamentOptions.test.ts` with 9 cases — the #1248 regression case, "one entry per cloud setting_id, no @printer collapse", "list each local preset individually", "@printer suffix preserved in displayName", local `allCodes` carrying both shapes, the `GFA00`↔`GFSA00` built-in dedup, the all-empty fallback, and the alphabetical sort. The two existing `vi.mock('../../api/client')` blocks in `SpoolFormModal.test.tsx` and `SpoolFormBulk.test.tsx` were updated with the new `getBuiltinFilaments` stub.
- **SpoolBuddy install.sh re-run failed with `Permission denied` on root-owned files in update mode** — `download_spoolbuddy()` ran `git fetch + git checkout + git reset --hard` *before* the post-install chown at the end of the function. If a previous install left stray root-owned files in the tree (e.g. `static/assets/*` written by an earlier `sudo` run, or a frontend build that wrote as root), the `git reset --hard` step aborted with EACCES on the unlink/replace step before reaching the chown. The script then exited and the kiosk's underlying ownership problem persisted, so the next attempt would fail the same way. **Fix:** pre-emptively `chown -R spoolbuddy:spoolbuddy "$INSTALL_PATH"` in the update branch *before* any git operation runs. The script already runs as root (enforced by `check_root`), so the chown is always safe. The existing post-install chown at the end stays — it now mostly catches new files created during this run that need their ownership normalised. Same root cause showed up on the kiosk's *runtime* SSH update path (Bambuddy → kiosk: `git checkout dev && git reset --hard origin/dev` running as the `spoolbuddy` user) but that path can't `chown` without sudoers expansion — the install.sh fix is the immediate recovery, and re-running the install script restores a clean ownership baseline that the runtime updater can keep healthy thereafter.
- **SpoolBuddy SSH update aborted with `TypeError: startswith first arg must be bytes or a tuple of bytes, not str` after the host-key store succeeded** — `perform_ssh_update` calls `asyncssh.import_known_hosts(...)` to materialise an `SSHKnownHosts` object for `_run_ssh_command`'s `known_hosts=` keyword arg. Both call sites (the stored-key path at line 221 and the just-stored TOFU re-parse at line 272) passed `f"{ip} {key}\n".encode()` — i.e. `bytes`. asyncssh's parser does line-based string operations (`line.startswith('#')` with a `str` literal), so any `bytes` input crashes inside its loader with `TypeError`. The two `try`/`except` clauses caught only `(ValueError, asyncssh.Error)`, missing `TypeError`, so the crash bubbled up and aborted the whole update right after the schema fix successfully persisted the host key. **Fix:** drop the `.encode()` at both call sites — pass the str directly. Widened both except clauses to `(ValueError, TypeError, asyncssh.Error)` so any future asyncssh API surprise degrades to the existing fallback (TOFU mode without host-key verification, with a logger.warning) instead of crashing the update. Existing SSH tests all mocked `asyncssh.import_known_hosts` itself so they never reached the parser — added `test_perform_ssh_update_passes_str_not_bytes_to_import_known_hosts` to capture both call sites' arguments and assert `isinstance(arg, str)` so re-introducing `.encode()` fails CI immediately.
- **SpoolBuddy SSH update crashed on Postgres with `value too long for type character varying(500)` when storing the device's RSA host key** — `spoolbuddy_devices.ssh_host_key` was declared as `String(500)`, which is fine for SQLite (ignores VARCHAR length) and for ed25519 host keys (~120 chars), but RSA host keys in OpenSSH format are typically 370 chars (2048-bit) → 544 chars (3072-bit) → ~720 chars (4096-bit). Postgres enforces the limit strictly, so any kiosk reporting an RSA-3072 or larger host key on the first SSH update aborted at the `UPDATE spoolbuddy_devices SET ssh_host_key=...` flush — the `git fetch + pip install + systemctl restart` may have run successfully but the persistence of the TOFU host key failed and the device's update_status was never written. **Fix:** widened `ssh_host_key` from `String(500)` → `Text` on the model, plus an idempotent `ALTER TABLE spoolbuddy_devices ALTER COLUMN ssh_host_key TYPE TEXT` migration gated on `not is_sqlite()` (Postgres-only; SQLite is a no-op since it doesn't enforce VARCHAR length). Existing rows are preserved — `TYPE TEXT` is a metadata-only change on Postgres for `VARCHAR(N)` → `TEXT` so it's a fast migration even on populated tables. Originally introduced in the H1 SSH-host-key TOFU security fix; the 500-char floor was a guess based on ed25519 sizes that the RSA case immediately blew past.
- **SpoolBuddy kiosk Settings → Update button returned "API keys cannot be used for administrative operations"** — Same root cause as the four QuickMenu System buttons fixed in 0.2.4b3 (Restart Daemon / Restart Browser / Reboot / Shutdown), missed in that audit. The `POST /spoolbuddy/devices/{id}/update` route (kiosk's own Settings → Update Daemon button → SSH update on the kiosk device) was gated on `Permission.SETTINGS_UPDATE`, but `SETTINGS_UPDATE` is on the API-key deny-list (`_APIKEY_DENIED_PERMISSIONS` in `backend/app/core/auth.py`, introduced in PR #1241). Every kiosk-side request to update the daemon — regardless of the API key's scope set (Read / Print Queue / Control / Legacy) — tripped the deny-list and returned a hard 403 with that message. **The 0.2.4b3 fix explicitly carved /update out** with the reasoning "replaces the daemon binary, different threat surface" — but that reasoning was wrong: `restart_daemon` already replaces the running daemon process, so daemon-replacement is *not* a step up in blast radius. The SSH update is also strictly scoped to the single device the operator physically controls (`git fetch + pip install + systemctl restart` on that one host) — same threat profile as the system commands already running on `INVENTORY_UPDATE`. **Fix:** lower `/spoolbuddy/devices/{id}/update` from `Permission.SETTINGS_UPDATE` → `Permission.INVENTORY_UPDATE`, matching the rest of the kiosk-scoped routes (`calibration/tare`, `display`, `cancel-write`, `system/command`, `system/command-result`, `update-status`). The main Bambuddy in-app updater at `POST /api/v1/updates/apply` keeps `SETTINGS_UPDATE` — that one operates on the Bambuddy host and is correctly fenced behind the deny-list. **Tests:** `test_trigger_update_requires_settings_update` (which pinned the broken behavior — 403 on inventory-only key) is renamed to `test_trigger_update_accepts_inventory_update` and now asserts the inventory-only key reaches the device-state check (409 offline) instead of 403, so a future re-tightening of the gate surfaces immediately. Class-level docstring in `test_settings_api_key_scrubbing.py` updated to reflect the corrected threat-model reasoning.
- **Printer file download 500'd on non-ASCII filenames; same crash latent in three sibling endpoints** ([#1245](https://github.com/maziggy/bambuddy/issues/1245), reported by @1000Delta) — `GET /api/v1/printers/{id}/files/download?path=...` raised `UnicodeEncodeError: 'latin-1' codec can't encode characters in position …` for any path whose filename carried non-ASCII characters (Chinese, Japanese, Arabic, accented Latin), reproducible against P2S firmware on macOS but not target-specific. **Cause:** the route shoved `filename` straight into `Content-Disposition: attachment; filename="{filename}"` — Starlette/uvicorn encodes response headers as latin-1, so anything outside U+0000..U+00FF crashed at write-time. Same pattern existed in three sibling endpoints reachable with user-controlled non-ASCII input: `GET /archives/{id}/qr` (uses `archive.print_name` from 3MF metadata, often non-ASCII), `GET /projects/{id}/export` (uses `project.name` — the existing sanitiser at `projects.py:1648` uses `c.isalnum()` which **passes non-ASCII Unicode through**, so the crash propagated), and `_stream_pdf` in `labels.py` (latent — current callers pass ASCII-only template names, but the same shape would crash if a future caller passed user input). **Fix:** new helper `backend/app/utils/http.py::build_content_disposition(filename, disposition="attachment")` returns an RFC 6266-compliant header with both an ASCII-stripped legacy `filename="..."` fallback and an RFC 5987 `filename*=UTF-8''<percent-encoded>` parameter — every modern browser (Chrome / Firefox / Safari / Edge) prefers the `*=` form when present, so the original filename round-trips intact through Save-As; the ASCII fallback covers IE10-era clients. Helper wired in at all four call sites in one PR (per project rule: no deferred follow-ups). **Tests:** 20 unit tests in `test_http_utils.py` pinning ASCII-fallback rules across plain ASCII / Chinese / Japanese / Arabic / French diacritics / `.gcode.3mf` double-extension / quote-injection / backslash-injection / empty-string and `___.zip` edge cases, asserting the helper's output round-trips through latin-1 (the crash condition) for every test input. 6 new integration tests in `test_printers_api.py::TestPrintersAPI::test_download_printer_file_non_ascii_filename` parametrized over the same character classes (the original `龙泡泡石墩子_p2s_ok.gcode.3mf` case from #1245 is included) — each asserts the route returns 200 with an unmangled body, the ASCII fallback in the header matches expectations, and `unquote(filename*=)` round-trips back to the original Unicode filename. Thanks to @1000Delta for the diagnosis and the proof-of-concept patch on `printers.py` — the broader audit (three sibling endpoints, helper extraction, latin-1 round-trip assertions) was done on top of that.

## [0.2.4b3] - 2026-05-08

### Added
- **Slicer Bundle (.bbscfg) import — pick presets from a stored bundle instead of resolving cloud/local/standard PresetRefs every slice** — Closes the long tail of preset-resolution corner cases (cloud presets behind login, "from User" sentinel handling, the `# `-prefix clone trick, dangling `inherits` on renamed parents, etc.) by letting users upload a BambuStudio "Printer Preset Bundle" (`.bbscfg`) once per printer and pick from it for every subsequent slice. **Service layer (`backend/app/services/slicer_api.py`):** `BundleSummary` / `BundleNotFoundError` types, `import_bundle` / `list_bundles` / `get_bundle` / `delete_bundle` methods, `slice_with_bundle` which posts `/slice` with bundle id + per-category preset names instead of the JSON triplet. **Routes (`/api/v1/slicer/bundles`, all gated on `Permission.LIBRARY_UPLOAD`):** `POST` / `GET` / `GET :id` / `DELETE :id`. All routes proxy via `_resolve_slicer_api_url` so they follow the user's `preferred_slicer` setting (bambu_studio vs orcaslicer). Status-code mapping treats sidecar 4xx as 400, `BundleNotFoundError` as 404, sidecar unreachable as 503, and sidecar 5xx as 502. **Preview-slice (`backend/app/services/slice_preview.py::get_preview_filaments`)** picks up optional `bundle_id` + `printer_name` + `process_name` + `filament_names` params and routes through `slice_with_bundle` when set; the cache key picks up a bundle-context fingerprint so different bundle picks on the same file occupy distinct entries — gram numbers in the preview now match what the real print will produce instead of being derived from the file's embedded process settings (which can drift from the triplet the actual slice would use). The `library.py` and `archives.py` `/filament-requirements` routes forward the new params. **Dispatch (`SliceRequest.bundle: SliceBundleSpec`):** when set, `_run_slicer_with_fallback` skips `resolve_preset_ref` and calls `slice_with_bundle`; the validator skips the preset-required check so bundle-only requests validate. 3MF + bundle CLI 5xx still falls back to the embedded-settings slice path (`used_embedded_settings=True` surfaces in the response), and sidecar 404 (unknown bundle / preset name) maps to 400. **Frontend SliceModal Bundle tier:** new "Slicer bundle" picker at the top of the modal, rendered only when at least one bundle is imported (`GET /slicer/bundles` non-empty). Selecting a bundle replaces cloud / local / standard preset dropdowns with bundle-scoped pickers (process + per-slot filament names from the bundle) — printer is implicit (each `.bbscfg` has exactly one). "None" leaves the modal on the original preset-triplet path. Submit routes through `SliceRequest.bundle` so the backend skips PresetRef resolution and asks the sidecar to materialise the JSON triplet from the stored bundle by name. **Frontend types:** `SliceBundleSpec` + `bundle?: SliceBundleSpec` on `SliceRequest`; `getLibraryFileFilamentRequirements` / `getArchiveFilamentRequirements` accept an optional 4th-arg bundle context object. The orca-slicer-api fork's bundle endpoints (shipped on `bambuddy/bundle-import`) are the server side of this — see the slicer-api sidecar docker-compose for the matching versions.

### Fixed
- **SpoolBuddy with Spoolman enabled: NFC tag scan looked up local DB first, ignored Spoolman setting; "Assign to AMS" did nothing on freshly-linked spools; AMS slot picker hid the assigned spool's info and unassign action; LinkSpoolModal showed "Unknown color" for every Spoolman spool; tag-write didn't enforce uniqueness so the wrong spool resolved on scan; kiosk display held stale assigned-state forever** — Several intertwined bugs surfaced during `feature/spoolman-inventory-ui` testing; fixing them as one batch because they all live on the SpoolBuddy + Spoolman path. **(1) `/spoolbuddy/nfc/tag-scanned` always tried local DB first** and only consulted Spoolman as a fallback on local-DB miss, so a stale local copy of a tag silently won over the authoritative Spoolman row, and deleting the local copy was the only way to surface the Spoolman match. Now the route gates on `_get_spoolman_client_or_none(db)` (which already encodes the `spoolman_enabled` setting + SSRF guard) and routes to whichever inventory backend Bambuddy is configured for — Spoolman exclusive when enabled, local exclusive otherwise. **(2) Dashboard "Assign to AMS" button was a no-op** when the freshly-matched spool wasn't yet in the cached `getSpoolmanInventorySpools` query result (newly created or unarchived in Spoolman after the dashboard loaded). The card rendered via its own `displayedSpool ?? sbState.matchedSpool` fallback, but the modal's stricter `displayedSpool && !justLinkedSpool && displayedTagId` guard silently failed to mount. New `effectiveModalSpool` synthesises an `InventorySpool`-shaped object from the WebSocket-delivered `MatchedSpool` (a 9-field subset; `slicer_filament*` are absent but the modal only uses `id` to route the assign API call and the mismatch check yields `'none'` for profile in either case). **(3) AMS-page slot picker hid the assigned spool entirely** — when a slot had a `SpoolmanSlotAssignment` (assigned via the dashboard's Assign-to-AMS flow) but no tag-linked spool, the picker explicitly returned `null` for the assign/unassign branch and only the "Configure" button remained visible. Now the picker resolves the assignment from `spoolmanSlotAssignmentsAll + spoolmanInventorySpoolsCache`, renders a "Assigned spool: brand · material - color" info card, and exposes an Unassign button wired to a new `unassignSpoolmanSlotMutation` (calls `DELETE /spoolman/inventory/slot-assignments/<id>`, mirroring the local-mode flow). **(4) `LinkSpoolModal` showed "Unknown color" for every Spoolman spool** because Spoolman doesn't standardise `color_name` — most installs only populate `color_hex` and the filament's `name` (which often carries the colour like "PLA Basic Red"). `_map_spoolman_spool` now falls back to the filament's subtype (filament name minus material prefix — typically "Basic Red") when `color_name` is empty, so spools are visually distinguishable in the picker without changes to the frontend. **(5) Writing a tag for spool B didn't clear the same tag binding from spool A**, so a single physical NFC UID could map to two Spoolman spools at once and `find_spool_by_tag` returned whichever came first in the cached list (typically the older one) — exactly the symptom maziggy hit during testing where re-writing a tag still surfaced the previously-assigned spool. `nfc_write_result` now searches Spoolman for any other spool currently bound to the target UID and clears its `extra.tag` (best-effort: cleanup failure logs a warning but doesn't block the write itself, since the device already wrote the chip). **(6) The kiosk display held stale `spoolmanSlotAssignments` cache** because the SpoolBuddy display is a long-running browser window with no focus/remount triggers, so a `staleTime` alone never caused a refetch. State changed elsewhere (Bambuddy main UI, direct Spoolman edit) was invisible to the kiosk and `isSpoolAssigned` reported assigned-forever — the Assign button stayed disabled, the Unassign button stayed enabled, after the spools were already unassigned. Adds `refetchInterval: 3_000` (cheap query, bounded latency below operator-noticeable) so the kiosk picks up external changes within seconds. **(7) Kiosk QuickMenu System buttons (Restart Daemon / Restart Browser / Reboot / Shutdown) all 403'd silently** — the `/spoolbuddy/devices/<id>/system/command` route was gated on `Permission.SETTINGS_UPDATE` (T-Gap 2 from a prior security audit), but every other kiosk-scoped device route (`calibration/tare`, `display`, `cancel-write`, `system/command-result`) uses `INVENTORY_UPDATE`. The kiosk's operator session has `INVENTORY_UPDATE` but not `SETTINGS_UPDATE`, so every System button silently failed via the modal's catch-block (no toast). Aligned the permission with the rest of the kiosk-scoped routes so operators can recover the kiosk from the kiosk itself. Risk is bounded — only the 4 named commands are accepted (no RCE), reboot/shutdown require physical-access recovery, the same operator already controls printers + weighs spools on the same device. The `/update` route keeps `SETTINGS_UPDATE` because that one can replace the daemon binary, which is a different threat surface. Test contract `test_system_command_requires_settings_update` is renamed to `test_system_command_accepts_inventory_update` and asserts the inventory-only key now reaches the device-state check (409 offline) instead of 403, so a future re-tightening of the gate surfaces immediately. **Tests:** new `TestMapSpoolmanSpool::test_color_name_uses_explicit_field_when_present` / `_falls_back_to_subtype_when_field_missing` / `_none_when_both_fields_empty` (3 unit tests pinning the colour-name fallback chain), new `TestNfcEndpoints::test_tag_scanned_spoolman_mode_skips_local_lookup` (verifies `get_spool_by_tag` is never called when Spoolman is enabled, even when the lookup would have returned a spool), and new `test_write_result_clears_duplicate_tag_binding` (asserts `merge_spool_extra` is called twice — once to clear the old holder's `extra.tag`, once to bind the new owner — in that order with the right spool ids). Existing 76 helper tests + 7 NFC-endpoint tests still pass.
- **Spool assignment to a reset AMS slot left the slot unconfigured both in Bambuddy and on the printer** — Reproduced during `feature/spoolman-inventory-ui` testing (extends the #1228 family). After clicking "Reset slot" on an AMS slot that had filament physically loaded, picking an inventory spool from the printer card and clicking Assign showed a success toast — but the slot kept reporting as unconfigured, no `ams_filament_setting` MQTT command ever fired, and the spool's brand/color never appeared on either the Bambuddy printer card or BambuStudio. **Cause:** `assign_spool` in `backend/app/api/routes/inventory.py` decided the slot was empty using `slot_is_empty = not (fingerprint_type and fingerprint_type.strip())` where `fingerprint_type` came from `tray.tray_type`. The "Reset slot" command clears `tray_type` / `tray_color` / `tray_info_idx` to empty strings on the printer side but leaves the filament physically loaded. The empty `tray_type` then misled the heuristic into the pending-config (SpoolBuddy weigh-then-assign) branch, which intentionally skips the MQTT publish because Bambu firmware drops `ams_filament_setting` on truly unloaded slots. The deferred replay in `on_ams_change` only fires on an empty→loaded transition — but the slot was already loaded, so no transition ever came and the assignment sat in pending state forever. **Fix:** capture `tray.state` alongside the fingerprint fields when looking up the AMS tray (Bambu firmware reports `state == 11` for loaded, `9` for empty, `10` for spool present but filament not in feeder; documented at `bambu_mqtt.py:1631-1633`). When `state` is reported, `slot_is_empty = (state != 11)`. When `state` is not reported (older firmware), fall back to the existing `tray_type` heuristic so legacy installs continue to behave the same. Same logic applied to the external-slot path (`ams_id == 255` / `vt_tray`). **Tests:** 5 new in `TestAssignSpoolEmptyDetection` — post-reset (`state=11, tray_type=""` → MQTT must fire, `pending_config=False`), genuinely empty (`state=9` → MQTT skipped, `pending_config=True`), legacy fallback both directions (no `state` field → tray_type heuristic), and the external-slot post-reset variant.
- **Slicer "Send to printer" silently rejected the cached push_status with "storage needs to be inserted" on P1S/A1-class targets** ([#1228](https://github.com/maziggy/bambuddy/issues/1228), reported by @rtadams89, also hit by @smandon) — Slicer "Send" worked on 0.2.3.2 with a queue-mode VP and started failing on 0.2.4b3, regardless of subnet topology, with BambuStudio showing the generic "storage needs to be inserted before send to printer" error. Reproducible across Docker bridge, macvlan, and LAN-attached host networking. Network reachability ruled out (slicer reaches MQTT/FTPS, FTP passive ports 50000-50100 reachable end-to-end, pfSense rules clean). The smoking gun was in @rtadams89's debug-level support archive: slicer establishes MQTT TLS to the VP, gets `pushall` + `get_version` responses, then **never opens an FTP connection** — the slicer reads the cached push, fails its pre-flight, and aborts before attempting any data transfer. **Cause:** the 0.2.3.2 synthetic stub baked three SD/storage indicators that BambuStudio's "Send" pre-flight reads — `home_flag` with bit 8 (`HAS_SDCARD_NORMAL`, `0x100`), `sdcard: True`, and a `storage: {free, total}` block. The 0.2.4b3 cached-as-base slicer-mirror (commit `7dea33d0`) passes the live target's push_status through with only an IP rewrite; if the real firmware doesn't report those fields (P1S/A1 with no SD card inserted, older field shapes, P1S firmware `01.10.00.00` confirmed in @rtadams89's logs), the slicer sees "no storage" and refuses to send. H2D and X1C in maziggy's local cross-subnet repro worked because those firmwares do report the indicators; P1S/A1-class doesn't always. **Fix:** in `mqtt_server.py:_send_status_report` cached-as-base path, after copying the cache, OR `0x100` onto `home_flag` (preserves any other bits the printer set), force `sdcard=True`, and `setdefault` a `storage: {free: 1_000_000_000, total: 32_000_000_000}` block (only fills in if the real printer didn't report one — real values pass through unchanged when present). For VP usage the slicer uploads via FTPS to Bambuddy's filesystem under `/app/data/virtual_printer/uploads/<vpid>/`; the printer's actual SD card is irrelevant on that path, so forcing "storage available" is correct for the queue/immediate/review modes the cached-as-base path covers. Restores 0.2.3.2's working behaviour for these specific fields without losing the live AMS / k-profile / camera mirror that cached-as-base provides. **Tests:** new `test_storage_indicators_overlaid_for_send_preflight` (verifies SD bit OR'd onto a partial `home_flag`, `sdcard=True` forced even when real says False, `storage` injected when cache lacks it, free/total are non-zero) and `test_storage_indicators_preserve_real_storage_when_present` (real `home_flag=0x100` stays `0x100`, real `storage={free, total}` passes through unchanged so the overlay never overrides what the printer actually reported) in `test_vp_mqtt_bridge.py::TestStatusReportCachedAsBase`. Existing 25 tests in that suite still pass.
- **MFA at-rest encryption is now default-on via auto-bootstrap** ([#1219](https://github.com/maziggy/bambuddy/issues/1219)) — Default Docker installs ran with `MFA_ENCRYPTION_KEY` unset, which silently fell back to plaintext storage for OIDC `client_secret` and TOTP secret rows. The single startup `logger.warning` was the only signal, and `.env.example` / `docker-compose.yml` / Settings UI never mentioned the variable, so any operator who wired up SSO or asked users to enroll in 2FA had to read the warning in the logs to know their secrets were unprotected at rest. **Auto-bootstrap:** `backend/app/core/encryption.py` now resolves the encryption key with the same precedence pattern as `_get_jwt_secret` — `MFA_ENCRYPTION_KEY` env var → `DATA_DIR/.mfa_encryption_key` file → auto-generated Fernet key written with mode `0o600`. The new helper `backend/app/core/paths.py:resolve_data_dir()` is shared with `auth.py` (DRY) and reads the env fresh on every call so test fixtures can override `DATA_DIR` per-test. Invalid env-var values (anything that doesn't decode to exactly 32 bytes via URL-safe base64) are rejected with a `logger.error` and the loader falls through to the file/auto-generate branches instead of crashing the encrypt/decrypt path with `ValueError`. **Re-encryption migration:** `_migrate_encrypt_legacy_secrets()` runs once on every startup after `run_migrations(conn)` finishes — it opens its own `async_session()` (separate from the schema-DDL connection, to avoid SQLite WAL lock contention) and converts any `oidc_providers.client_secret` / `user_totp.secret` row whose value doesn't already start with `fernet:` to the encrypted form via the existing property setters. The migration is idempotent (prefix check) and is a no-op when no key is loaded, so it can run safely on installs that never opt in. **Status endpoint + UI:** new `GET /api/v1/auth/encryption-status` (admin-only, gated on `Permission.SETTINGS_READ`) returns `key_configured`, `key_source ∈ {env, file, generated, none}`, plus per-table `legacy_plaintext_rows` and `encrypted_rows` counts and a derived `decryption_broken` flag (true iff encrypted rows exist but no key is loadable — the Phase-2 "operator deleted the key after rows were encrypted" recovery scenario). The new `frontend/src/components/SecurityStatusCard.tsx` lives in a new "Security" sub-tab under Settings → Authentication and renders four severity levels: green when everything is encrypted and a key is loaded, yellow when legacy plaintext rows still need re-encryption, orange when the key was auto-generated (with a backup hint pointing at `DATA_DIR/.mfa_encryption_key`), and red when `decryption_broken` is true. **Backup integration:** `routes/settings.py:create_backup_zip` now includes `.mfa_encryption_key` as a ZIP top-level entry (alongside `bambuddy.db`) so a self-contained backup can be restored to a fresh host without losing access to encrypted secrets. The matching `routes/settings.py:restore_backup` extracts the file back into `DATA_DIR` with `chmod(0o600)` and validates the basename exactly (`/`, `..`, `\\` rejected) so a manipulated ZIP cannot path-traverse outside `DATA_DIR`. If the file is absent from the ZIP (legacy backup) the restore proceeds without error — the next boot will auto-bootstrap a fresh key, and any plaintext rows that come back from the backup remain readable via the existing legacy-plaintext fallback in `mfa_decrypt`. **Test isolation:** new autouse `mfa_encryption_isolation` fixture in `conftest.py` per-test points `DATA_DIR` at a `tmp_path`, clears `MFA_ENCRYPTION_KEY` from env, and resets the `_fernet_instance` / `_warn_shown` / `_key_source` module globals — so the auto-bootstrap can never write a real key file into the repo and pytest-xdist workers don't share encryption state. **i18n:** new `settings.encryption.*` namespace and `settings.tabs.security` label across all 8 locales (en + de fully translated; fr/it/ja/pt-BR/zh-CN/zh-TW seeded with English copy pending native translation, matching the project's existing flow for newly-added keys). **Docs:** `.env.example` documents the new variable + the backup self-containment behaviour; `docker-compose.yml` carries an auto-commented entry; `.gitignore` adds `.mfa_encryption_key` alongside the existing `.jwt_secret` project-root guard. **Tests:** 9 new unit tests in `TestEncryption` (env/file/generated key sources, invalid-env fall-through, OSError → `none`, mode `0o600` check), 6 new in `TestEncryptLegacyMigration` (plaintext → encrypted for OIDC + TOTP, idempotent re-run, mixed state, no-op without key, log assertion), 8 new in `TestEncryptionStatusEndpoint` (each `key_source`, count assertions, `decryption_broken` recovery scenario, `Permission.SETTINGS_READ` gate), 2 new in `TestEncryptionRoundtrip` (raw column reads return ciphertext, property reads return plaintext for both OIDC and TOTP), 6 new in `TestBackupKeyFiles` (ZIP includes / skips key files, restore chmod `0o600`, missing-file tolerance, path-traversal rejection), and 6 new frontend tests in `SecurityStatusCard.test.tsx` (each severity level + the disabled state).
- **Camera preview popup opened to a blank page; deep-route refresh and direct URL load broken** ([#1221](https://github.com/maziggy/bambuddy/issues/1221), reported by @enjoylifenow / @Haeckan / @elit3ge / @jc21) — Clicking "open camera in new window" from the printer card opened a popup that rendered as an empty white page across P1S / P2S / X1 series, every install method (Docker / git clone), every browser (Chrome / Firefox / Brave / Safari), starting with the daily build of 2026-05-05. **Cause:** PR #1195 (`d6a31393`, "fix(frontend): emit relative asset paths so SPA loads under any subpath") set `base: ''` in `vite.config.ts` to support path-prefixed reverse proxies (HA Ingress, nginx subpath, Cloudflare Tunnel path routing). With that, the built `index.html` references its bundle and stylesheet via relative URLs (`./assets/index-XXX.js`, `./sw-register.js`). When the popup opened at `/camera/<id>`, the browser resolved `./assets/index-XXX.js` against the current document URL — which doesn't end in a slash, so the URL parser treated `<id>` as a file and `/camera/` as the directory, giving `/camera/assets/index-XXX.js`. The backend's SPA catch-all returned `index.html` (text/html) for that request, and modern browsers refuse to execute HTML as a JS module under `X-Content-Type-Options: nosniff`, so the popup loaded the document but never the bundle. Same break hit any deep route on initial load — direct URL paste / refresh on `/camera/:printerId`, `/projects/:id`, `/groups/:id/edit`, `/files/trash`, `/external/:id`, and the SpoolBuddy kiosk's `/spoolbuddy/ams` if loaded directly — manifesting as a quiet "blank page on refresh" that users worked around by navigating from the home page. The console error gives it away: `Loading module … was blocked because of a disallowed MIME type ("text/html")`. **Fix:** revert PR #1195's `vite.config.ts` and `sw-register.js` changes — `base: ''` is removed (Vite default `'/'` restored), and `navigator.serviceWorker.register('sw.js')` reverts to `register('/sw.js')`. The built `index.html` now emits absolute asset URLs (`/assets/...`, `/manifest.json`, `/sw-register.js`) which resolve against host root regardless of document URL, so deep routes load their assets correctly on initial navigation. PR #1195's class of bug — path-prefixed reverse proxy users serving Bambuddy at a subpath — was already explicitly closed as wontfix in that thread because supporting it requires subpath-aware bootstrapping (API_BASE, React Router basename, PWA manifest scope, service-worker scope, push-subscription scope) for every user forever. The supported workaround for that audience is documented: NPM (Nginx Proxy Manager) addon + Cloudflare Tunnel at a real domain with HTTPS, then HA Webpage panel embedding via `TRUSTED_FRAME_ORIGINS` — that path doesn't depend on `base: ''` at all. The trade-off here is intentional: revert reaches every user impacted by deep-route initial-load bugs (much larger population than path-prefixed proxy users), in exchange for an already-wontfixed subpath proxy regression that has a working alternative. ([#1237](https://github.com/maziggy/bambuddy/issues/1237), reported by @basziee) — In the Configure AMS Slot modal, profile names like `SUNLU PETG GLOW IN THE DARK GEN2 @Bambu Lab H2C 0.4 nozzle` were visually truncated mid-name, hiding the `@<printer> <nozzle>` suffix. With several near-identical entries differing only in nozzle size, users had to open browser dev tools to tell them apart. **Fix:** the preset row now expands inline on hover — `truncate` stays as the default (so the list keeps its compact one-line shape) but `group-hover:whitespace-normal group-hover:break-all` flips it to a wrapped multi-line view the moment the cursor enters the row, so the nozzle suffix is readable instantly without waiting on the browser's title-tooltip delay. The parent button gets `group` to drive the hover. The native `title={preset.name}` is also added as a belt-and-braces fallback for assistive tech and touch devices where `:hover` doesn't fire. Same pattern in both the desktop and mobile layouts of `ConfigureAmsSlotModal.tsx`. No new dependencies. **Test:** new `ConfigureAmsSlotModal.test.tsx` regression assertion that the rendered preset span carries `title=<full name>` plus the `truncate` and `group-hover:whitespace-normal` classes, and the parent button has `group` — so a future refactor that drops any of those fails CI.
- **Filament usage double-counted when AMS auto-falls-back to a same-material spool** ([#957](https://github.com/maziggy/bambuddy/issues/957)) — When one spool ran out mid-print and the AMS transparently switched to a sibling slot loaded with the same material, the usage tracker credited the originally-mapped spool with the full 3MF estimate AND added the fallback spool's remain%-delta on top — so a 78 g print could show as 78 g + 60 g = 138 g consumed across the two spools, leaving the empty spool's recorded weight beyond its label weight (the symptom the original report flagged on a 1209 g spool reading "1188.30 g used" while the new spool only got a 30 g credit). Two interacting bugs: (1) the tray-change recorder in `bambu_mqtt.py` gated on `state in ("RUNNING", "PAUSE")` literal strings, and P2S firmware briefly transitions out of RUNNING during the AMS swap, so the switch was never appended to `tray_change_log`; (2) the usage-tracker splitting branch in `usage_tracker.py` was gated on `not slot_to_tray`, so even when the tray-change log was populated the splitting code only ran for prints where the slicer's mapping had not been captured — i.e. never on the actual fallback case. **Fix:** the `bambu_mqtt.py` gate now keys on the print-lifecycle flags (`_was_running and not _completion_triggered`) so any tray change between print start and completion is captured regardless of the momentary `gcode_state` string. The `usage_tracker.py` gate is split so `tray_change_log` evidence with > 1 entries always takes over from `slot_to_tray`, treating the per-segment per-layer gcode usage as the source of truth when the printer actually fed from multiple trays. Path 2 (AMS remain%-delta fallback) then naturally skips both trays because they're already in `handled_trays` after splitting, eliminating the double-credit. **Tests:** new `test_tray_change_recorded_during_intermediate_state` and `test_tray_change_not_recorded_after_completion` in `test_bambu_mqtt.py` exercising the new gate; new `test_tray_switch_overrides_print_cmd_mapping` in `test_usage_tracker.py` pinning that with `ams_mapping=[0]` set and `tray_change_log=[(0,0),(1,30)]` the splitter produces two segments summing to the 3MF estimate (no double-count) and adds both `(0,0)` and `(0,1)` to `handled_trays`.
- **3D Preview returned `{"detail":"Not Found"}` in Docker installs** ([#1218](https://github.com/maziggy/bambuddy/issues/1218)) — The embedded GCode viewer's static assets (`gcode_viewer/`) were not copied into the production Docker image, so clicking "3D Preview" on any archive loaded an iframe at `/gcode-viewer/?archive=<id>` that returned a bare FastAPI 404 — Firefox / Chrome rendered the JSON response inside the iframe area while the outer Bambuddy layout looked normal, masking the failure unless the user actually inspected the iframe. The Vite production build doesn't stage `gcode_viewer/` into `static/` either (the dev server serves it via a `configureServer` middleware that's dev-only), and the only integration test for the route accepted `404` as a valid outcome ("`assert response.status_code in (200, 404)`") so CI never caught the missing files. Affected every Docker build since the embedded viewer landed in 0.2.4b1 (commit `3adce435`, 2026-04-22). **Fix:** `Dockerfile` now copies the `gcode_viewer/` directory alongside the React build output. **Defence in depth:** `backend/app/main.py` logs an ERROR at startup when `_gcode_viewer_dir / "index.html"` is missing so future packaging gaps surface in `docker logs` and the support bundle instead of as silent runtime 404s. **Test guard:** `backend/tests/integration/test_gcode_viewer.py` adds `test_gcode_viewer_index_served_when_assets_present` which skips when the directory is intentionally absent (unit-test environments) but asserts `200 OK` + a non-empty HTML body when the assets do exist on disk — so a future broken `COPY` fails CI loudly rather than continuing to ship a broken image.
- **Slice button no longer enabled before the preview slice resolves** — Until the preview slice (or embedded-metadata read for already-sliced 3MFs) returned the per-plate filament list, the SliceModal rendered a synthetic single-slot fallback so the auto-pick had something to bind against. That made the Slice button enabled the moment the modal opened, even before the slicer had told us which AMS slots the plate actually consumes — clicking would dispatch against opaque defaults and the real-life print would either pick the wrong filament or fail with a slot-mismatch error after the fact. Adds `filamentReqsQuery.isSuccess` to the `isReady` chain so the button stays disabled while the preview slice is in flight (or before the backend's `/filament-requirements` call settles for sliced files) and flips to enabled the moment the real slot list lands and auto-pick fills it.
- **New AMS RFID rolls auto-named to the wrong colour when the hex is shared across material variants** ([#1227](https://github.com/maziggy/bambuddy/issues/1227)) — Inserting an Ivory White (PLA Matte) roll always created a spool named "Jade White" because the colour-catalog lookup in `create_spool_from_tray` filtered by manufacturer + hex only, with no `ORDER BY`. Three Bambu Lab catalog rows share `#FFFFFF` — Jade White (PLA Basic), Ivory White (PLA Matte), White (PLA Silk) — and SQLite returned them in rowid order, so the first-inserted entry (Jade White) won every time regardless of the actual material the AMS reported. Same class of bug bites any other shared-hex pair across PLA Basic / Matte / Silk; the whites were just the most visible. **Fix:** `spool_tag_matcher.py::create_spool_from_tray` now filters the catalog by `tray_sub_brands` too — the printer-reported material variant ("PLA Matte" / "PLA Basic" / "PLA Silk") matches the catalog's `material` column directly. The query also gets an explicit `ORDER BY id` so the fallback path (when `tray_sub_brands` is empty — third-party spools / OpenTag tags) is deterministic across SQLite + PostgreSQL instead of DB-implementation-defined. The catalog lookup uses the *raw* `tray_sub_brands` value (before the gradient/dual/tri-color subtype upgrade at lines 73-87) because the catalog stores `"PLA Basic"` for gradient rolls too — the upgraded subtype lives on the spool, not the catalog row. **Note for affected users:** spools already in the database under the wrong colour name (e.g. four Ivory White rolls labelled "Jade White") don't auto-correct on next AMS read — the matcher only fires when *creating* a new spool from RFID. Existing rows need a manual rename in Inventory after upgrading. **Tests:** 4 new in `test_spool_tag_matcher.py` — `test_ivory_white_pla_matte_resolves_to_ivory_not_jade` (the #1227 regression pin), `test_pla_silk_white_resolves_to_white_not_jade` (the third collision), `test_jade_white_pla_basic_still_resolves_correctly` (happy-path guard with all three #FFFFFF entries seeded), and `test_unknown_material_falls_back_to_hex_only_lookup` (third-party / empty `tray_sub_brands` path stays deterministic via ORDER BY).
- **Backups to Gitea / Forgejo failed with "Failed to create tree" on empty repos and "list indices must be integers or slices, not str" on populated repos** ([#1224](https://github.com/maziggy/bambuddy/issues/1224), [#1225](https://github.com/maziggy/bambuddy/issues/1225)) — Two interacting bugs in the Gitea/Forgejo backend, both inherited from `GitHubBackend` because PR #1160's class docstring assumed Gitea's Git Data API was fully GitHub-compatible. (1) **List-shaped ref response:** `GET /api/v1/repos/{owner}/{repo}/git/refs/heads/{branch}` returns a *list* of matching refs on Gitea/Forgejo even when only one matches (`[{"ref": ..., "object": {"sha": ...}}]`), whereas GitHub returns a single object. The inherited `push_files` and `_create_branch_and_push` did `ref_response.json()["object"]["sha"]` and crashed with `list indices must be integers or slices, not str` — surfacing as the failure at the top of any push against a populated Gitea repo (#1225's symptom, and #1224's symptom once the user committed any file before the first backup). (2) **Empty-repo writes refused:** GitHub's Git Data API accepts `POST /git/blobs` against a brand-new empty repo and creates the initial commit + branch implicitly. Gitea refuses every blob/tree/commit POST with 404 until the underlying git repo has at least one commit — so the inherited `_create_initial_commit` (which posts blobs → tree → commit → ref in that order) silently failed: every blob POST returned 404, `tree_items` ended up empty, and the next tree POST also returned 404 ("Failed to create tree" — #1224's symptom on a freshly-created empty Gitea repo). **Fix:** `GiteaBackend` now overrides `push_files`, `_create_branch_and_push`, and `_create_initial_commit` directly instead of inheriting them. The Git Data API path uses a `_ref_sha()` helper that accepts both list and dict shapes; the empty-repo bootstrap route uses Gitea's Contents API (`POST /api/v1/repos/{owner}/{repo}/contents` with a `files` array, `branch=<target>`, `new_branch=<target>`) which seeds the initial commit + branch in a single transaction — Contents API is documented to work on empty repos because it goes through Gitea's higher-level repo-init path. `GitHubBackend` is **untouched** — the GitHub backup path is proven working, the fix is fully isolated to the Gitea side. `ForgejoBackend(GiteaBackend)` inherits both fixes automatically; tests pin that. **Tests:** 10 new tests in `test_git_providers.py` — `TestGiteaBackendListShapeRefResponse` (4 tests: `_ref_sha` accepts list/dict/empty-list, plus full `push_files` happy paths against list-shaped branch ref and list-shaped default-branch ref), `TestGiteaBackendEmptyRepoInitialCommit` (4 tests: empty repo routes through Contents API exclusively with no blob/tree/commit/ref Git Data API calls, payload shape verified field-by-field against Gitea's documented schema, error truncation works, empty file dict returns `skipped` without firing a useless API call), and `TestForgejoInheritsGiteaFixes` (2 tests: list-shape and empty-repo paths both work via inheritance). Existing 6 `TestGiteaBackendPushFiles` tests still pass since `_ref_sha` accepts dict-shaped responses too. Total: 78 tests pass across the backup unit + integration suites; ruff clean. **Follow-up fix (still under #1224):** subsequent backups against Gitea 1.24+ then failed with the opaque "Backup failed: 'tree'" because Gitea's `GET /repos/{owner}/{repo}/git/commits/{sha}` returns the wrapped `Commit` schema (tree at `commit.tree.sha`), whereas GitHub's same-named Git Database endpoint returns the unwrapped `GitCommit` schema (tree at top level). The bare `commit_response.json()["tree"]["sha"]` lookup at `gitea.py:109` raised `KeyError: 'tree'` and the broad `except` surfaced it as the opaque message. **Fix:** `_commit_tree_sha()` helper that tries the flat shape first (GitHub-compatible / older Gitea) and falls back to the wrapped shape (Gitea 1.24+, Forgejo) — keeps the existing-files diff working on both shapes so subsequent backups don't re-upload every blob. **Tests:** new `TestGiteaBackendWrappedCommitResponse` (4 tests: helper accepts flat / wrapped / missing shapes, full `push_files` succeeds against a wrapped commit response, failure path surfaces a clear error message instead of `KeyError` when the tree SHA can't be extracted).
- **Docker data-volume ownership normalised at startup via gosu entrypoint** ([#1211](https://github.com/maziggy/bambuddy/issues/1211)) — Two long-standing failure modes have been biting Docker users repeatedly: (1) Docker named volumes are created by the daemon as `root:root`, and the previous `chmod 777 /app/data` Dockerfile workaround only covered the named-volume root — so subdirs Bambuddy creates at runtime (`virtual_printer/uploads`, `virtual_printer/certs`, etc.) inherited wrong ownership when the container ran as `1000:1000`. (2) The shipped `docker-compose.yml` ships `./virtual_printer:/app/data/virtual_printer` uncommented, and dockerd creates a missing bind-mount source on the host as root before the container starts — leaving the host directory unwritable by uid 1000 inside the container even though the named volume above it had the chmod-777 workaround. Symptom either way: `[Errno 13] Permission denied: '/app/data/virtual_printer/uploads'`, no virtual printer ever starts, "VP doesn't work" support reports follow. **Replaces the chmod-777 hack with a proper entrypoint:** `deploy/docker-entrypoint.sh` runs as root, chowns `/app/data` and `/app/logs` (and `/app/data/virtual_printer` when bind-mounted) to `PUID:PGID`, then drops to that uid via `gosu` before `exec`'ing the app. The chown is gated behind a top-level ownership check so subsequent restarts skip the recursive traversal — no multi-second startup penalty on multi-GB archive directories. A sentinel `.bambuddy` file in each data path prevents Docker from re-syncing image directory metadata on every mount (otherwise empty volumes have their ownership reverted from the image on each restart, defeating the idempotency). When the container is started with an explicit `user:` directive or `--user` flag the entrypoint detects it isn't root and falls through to direct `exec` — preserving compatibility for users who pin a specific uid. **Compose template changes:** removes `user: "${PUID:-1000}:${PGID:-1000}"` (the entrypoint owns privilege drop now), adds `PUID` / `PGID` env vars with the same defaults, and comments out the `./virtual_printer:/app/data/virtual_printer` bind mount by default with explicit "only needed if you also run a native install of Bambuddy on the same host and want both to share the VP CA cert" guidance. The entrypoint chowns the host-side dir through the bind mount the first time it sees wrong ownership, so existing uncommented installs continue to work and #1211 specifically gets fixed.
- **Label picker modal clipped the 4th template option and Cancel button on short viewports** ([#1230](https://github.com/maziggy/bambuddy/issues/1230), reported by @elit3ge) — Clicking "Print labels" from Inventory opened the picker with only 3 of the 4 templates visible (Avery 5160 was half-cut at the bottom) and no Cancel button reachable, with no way to scroll to them. Surfaced reliably on Windows 11 + Brave at 1080p with browser chrome / DPI scaling shrinking the effective viewport, but the layout bug hits anywhere the modal's `max-h-[90vh]` lands below ~770 px. **Cause:** `LabelTemplatePickerModal.tsx` uses a flex column with `overflow-hidden` on the outer modal, the spool list as the `flex-1` shrinkable child, and the templates section + footer as fixed siblings below it. The spool list had `min-h-[160px]`, which combined with the default `min-height: auto` for flex items meant the spool list couldn't yield space when the modal was tight — the templates and footer overflowed the modal's bottom edge and got clipped. **First fix (insufficient):** `min-h-[160px]` → `min-h-0` on the spool list scroller, which both removes the fixed floor and overrides the implicit `min-height: auto`. That made the spool list shrink, but on the user's 838 px viewport with browser chrome eating into 90 vh the four stacked templates (~310 px) plus footer still didn't fit, leaving Avery 5160 half-cut and the Cancel button below the modal's clipped bottom edge — `elit3ge` confirmed the dev build was still broken after that fix. **Second fix:** the templates section now renders as a responsive grid (`grid-cols-1 sm:grid-cols-2 gap-2`) so the four buttons pack into a 2×2 grid above the `sm` breakpoint, trimming ~150 px of vertical inside the modal. Each cell tightens its label/hint to `text-sm` + `truncate` (with the full strings reachable via the new `title=label — hint` on the button so the truncation never hides information), padding shrinks to `p-2.5`, and the footer's `py-3` is dropped to `py-2` for a few extra pixels. The earlier `min-h-0` on the spool list is kept as a belt-and-braces shrink for any viewport tighter still. Pre-existing on `dev` since 0.2.4b2 (commit `864e5c99`, the original PR #809 that introduced the modal); not a regression from the spoolman-inventory rebase. **Test:** the regression test in `LabelTemplatePickerModal.test.tsx` is upgraded to pin the new structural shape — the templates container has `grid` + `grid-cols-1` + `sm:grid-cols-2` and exactly 4 child buttons, plus the existing assertions that all 4 template names + the Cancel button render and the spool list scroller still has `min-h-0` with no fixed `min-h-[…]` literal. So a future refactor that drops the grid and reintroduces stacked rows fails CI.

### Security
- **urllib3 floor raised to 2.7.0 to clear CVE-2026-44431 and CVE-2026-44432** — `urllib3` is a transitive dependency (none of Bambuddy's top-level deps require `>=2.7.0` yet), so the resolver was silently keeping the vulnerable `2.6.x` line. `requirements.txt` now carries an explicit `urllib3>=2.7.0` pin under the HTTP-client section with an inline comment explaining the indirect-dep rationale, so a future `pip install -r requirements.txt` rebuild picks up the upstream-fixed release. No Bambuddy code change — the affected code paths live inside urllib3 itself.

- **python-multipart bumped to 0.0.27 to clear CVE-2026-42561** — `requirements.txt` floor raised from `>=0.0.26` to `>=0.0.27`. python-multipart is the multipart/form-data parser FastAPI uses for `UploadFile` body parsing, so it sits on every Bambuddy upload path (3MF/STEP/STL upload, label-template imports, OIDC certificate upload, backup restore, etc.). The advisory is a parser-side issue against malformed multipart input; Bambuddy doesn't expose unauthenticated upload endpoints (every multipart route is gated on either `Permission.LIBRARY_UPLOAD` / `SETTINGS_UPDATE` / `INVENTORY_UPDATE`), so blast radius is bounded to authenticated callers — but the bump is mechanical and the floor was already loose, so no reason to wait.

## [0.2.4b2] - 2026-05-05

### Changed
- **Virtual Printer Tailscale toggle no longer provisions Let's Encrypt certs — it's now informational** — The original promise of the `tailscale_disabled` toggle was that flipping it on would obtain an LE cert via `tailscale cert` so users wouldn't need to import Bambuddy's CA into the slicer. End-to-end testing exposed that this was always going to fail: BambuStudio and OrcaSlicer both refuse hostname input in the Add Printer dialog (IP-only), and — more fundamentally — their printer-MQTT trust path validates only against the bundled BBL CA store (`printer.cer`), **not** the system trust store. Confirmed against ClusterM/open-bambu-networking's clean-room reimplementation: `mosquitto_tls_set(BBL_CA)` + `mosquitto_tls_opts_set(verify_peer=1)` + `mosquitto_tls_insecure_set(true)` — chain validation against BBL CA only, hostname check intentionally skipped (because Bambu's printer cert CN is the device serial, not an IP/hostname). LE-issued certs don't chain to BBL CA, so the slicer rejects with the well-known "-1" before any hostname/IP logic runs. The cert-import step is unavoidable; the LE provisioning was dead code for slicer connections. **What stays:** the toggle, the `/virtual-printers/tailscale-status` route, the docker socket mount, and the host-level Tailscale information surfaced on the VP card (IP + MagicDNS hostname + copy button) so users know what to paste into the slicer when they pick the Tailscale interface from the bind_ip dropdown. Tailscale's role is now strictly **network reach** — private WireGuard tunnel to the VP from any tailnet device, no port forwarding — exactly the same trust burden as LAN. **What goes:** `provision_cert` / `ensure_cert` / `cert_needs_renewal` and the daily renewal task / restart-on-renewal plumbing on the manager (`_cert_renewal_task`, `_cert_restart_task`, `_cert_renewal_loop`, `_restart_for_cert_renewal`, `_cancel_renewal_task`, `_cancel_restart_task`); the `tailscale_fqdn` field surfaced via VP status (cert side-effect); the `tailscale_not_available` 409 guard on toggle-enable in both `routes/virtual_printers.py` and `routes/settings.py` (toggle is informational, daemon presence doesn't block flipping it); `CertificateService.{ts_cert_path, ts_key_path, use_tailscale_cert}` and the LE cert files on disk (`virtual_printer_ts.{crt,key}` left in place per-VP — harmless residue, can be deleted manually). The `tailscale_disabled` DB column is **kept** as the persisted toggle state. Tailscale FQDN/IP on the VP card is now sourced from the existing `/tailscale/status` endpoint (host-level) rather than from per-VP cert provisioning side-effect — the data is the same regardless of which VP you're looking at, since each host has one Tailscale identity. **Wiki, README, and i18n copy updated across all 8 locales** to drop the "no cert import needed" framing — toggle's helper text now says it surfaces the Tailscale address and that CA import is unchanged. **Tests:** `test_tailscale.py` reduced to the surviving `get_status` cases (binary missing, command fails, success, empty DNSName, malformed JSON); `test_virtual_printer.py::test_sync_from_db_restarts_on_tailscale_disabled_change` rewritten as `test_sync_from_db_does_not_restart_on_tailscale_toggle` (toggle is informational — `remove_instance` must NOT be called when only `tailscale_disabled` changes); `test_virtual_printer_api.py::TestVirtualPrinterTailscaleGuardAPI` collapsed to a single `TestVirtualPrinterTailscaleToggleAPI::test_toggle_does_not_consult_tailscale_daemon` that asserts both directions succeed and `get_status` is never called. Frontend `VirtualPrinterCard.test.tsx` mock now stubs `getTailscaleStatus` and the FQDN-copy block drives the FQDN through that query rather than VP status.

### Added
- **Spool label printing** ([#809](https://github.com/maziggy/bambuddy/issues/809)) — Closes the longest-standing inventory gap: there's now a per-spool "Print label" button on every Inventory card and a "Print labels (N)" header action that prints labels for the currently filtered view. Generates a PDF in one of four fixed sizes — AMS holder (30×15 mm) for the popular Makerworld AMS Filament Label Holder, single box label (62×29 mm) for Brother PT/QL or Dymo small labels, Avery L7160 for A4 sheet stock (38.1×63.5 mm × 21 per page), and Avery 5160 for US Letter sheet stock (25.4×66.7 mm × 30 per page) — and opens it in a new browser tab so users can print or save. Each label shows a colour swatch (with multi-colour gradient stripes for spools that have `extra_colors` set), brand + material, the spool's own name, the **spool ID** (the field bsaunder flagged as the most-needed for "find spool 7 in my closet" identification), and a **QR code that deep-links to `/inventory?spool=<id>`** so a phone scan jumps straight back to that spool's row in Bambuddy. The box-size template additionally surfaces the storage location field. **Architecture:** `backend/app/services/label_renderer.py` is a pure-Python renderer using ReportLab (no headless browser, no system libs) and qrcode (already a dependency); the QR target uses the configured `external_url` setting if present so phone scans reach the right hostname, otherwise falls back to the request's own scheme+host. Renderer is fully decoupled from the SQLAlchemy model — input is a `LabelData` dataclass list — so the same code path serves both the local DB inventory and the Spoolman-backed inventory once the dedicated UI lands. Two endpoints: `POST /inventory/labels` (local) and `POST /spoolman/labels` (Spoolman-backed; fetches via the existing client and filters in-memory). Both gated on `Permission.INVENTORY_READ`, both cap requests at 500 spools per call to bound rendering time, both stream `application/pdf` directly. **Why server-side and not browser print?** Server-side gives consistent output across browsers, Avery sheet templates that align to <0.1 mm (browser print scaling drifts 2–3 mm per page), one-click "download all 30 selected as one PDF", no print-dialog header/margin fiddling, and reproducible output for support — at the cost of one new pure-Python dep and ~250 lines of layout code. **Out of scope for V1:** direct-to-label-printer drivers (Dymo / Brother / Zebra ZPL — each is its own multi-week project, follow-up issue per vendor if demand surfaces), user-customizable HTML/CSS templates / template DSL (the four built-ins cover the use case bsaunder articulated; templating engines are where this kind of feature usually drowns), and the "global label mixin for spools/projects/printed parts" framework Keybored02 sketched (right direction for a future feature, not for V1). On the dev branch the local-mode UI is wired; the Spoolman-mode UI defers to the in-flight `feature/spoolman-inventory-ui` branch where the unified Spoolman picker lives. **Tests:** 15 unit tests in `test_label_renderer.py` (each template produces a valid PDF, empty input returns valid empty PDF, unknown template raises, multi-colour swatch survives 4+ stops, missing optional fields don't crash, malformed rgba falls back to grey, long strings are truncated not overflowed, sheet templates paginate when count exceeds one sheet, QR-bearing PDFs are noticeably larger than QR-less ones); 11 integration tests in `test_labels.py` (both modes produce PDFs, all four templates succeed, unknown template / empty list / unknown spool ID rejected with the right code, request order preserved into the renderer so Avery sheets match the on-screen list, Spoolman path returns 400 when disabled / 503 when unreachable / 404 when spool missing / 200 with the expected content-type when it works, request body capped at MAX_LABELS_PER_REQUEST); 7 frontend tests in `LabelTemplatePickerModal.test.tsx` (modal absent when closed, four templates rendered, singular vs plural subtitle, spoolmanMode false routes to local API and vice versa, neither API called when the other mode is active, error path keeps the modal open so the user can retry). All 8 locales get the new `inventory.labels.*` key set with English strings (other locales seeded with English copy pending native translation, matches the project's existing flow for newly-added user-facing features).
- **Virtual Printer non-proxy modes now mirror the live target printer to the slicer** ([#1193](https://github.com/maziggy/bambuddy/issues/1193) follow-up) — Until now, Immediate / Review / Print Queue VPs looked like a stub Bambu Lab printer to the slicer: AMS dropdowns were empty, no live state, no camera, no per-filament k-profile lookup. The user could send a sliced file and that was it. With this change, the VP fans out the **target printer's live MQTT state** to the slicer (AMS units, FTS / dual-extruder routing, nozzle, temps, k-profiles, AMS load / dry / calibration commands) and proxies the **camera RTSPS stream** on port 322 — so the slicer treats the VP as a fully-functional Bambu printer while Bambuddy's queue / archive / dispatch features stay in the loop. **Architecture (cached-as-base, single source of truth):** the bridge caches the latest real `push_status` and `info.get_version` response from Bambuddy's existing per-printer MQTT subscription (no second session on the printer — firmware in-flight budget unaffected, see #1164). The VP's `_send_status_report` returns a near-byte-identical copy of the real push with only the upload-state-machine fields (sequence_id, command, msg, gcode_state, gcode_file, prepare_percent, subtask_name) overridden under our control, so BambuStudio's Send pre-flight sees exactly the same shape as a direct-to-printer connection. Command responses (extrusion_cali_get, AMS write acks, xcam responses) are fanned out raw — they carry sequence_ids the slicer is waiting on. Slicer-issued commands forward to the real printer except `print.project_file` / `gcode_file`, which are still answered locally because the file lives on Bambuddy. **Field-shape gotchas worth remembering:** (1) Real Bambu printers wire-format push_status JSON with `indent=4` (32 254 bytes for an idle H2D push, vs 14 268 bytes compact) — BambuStudio's Send pre-flight rejects compact JSON silently, so `_publish_to_report` was switched to `json.dumps(payload, indent=4)`. (2) `net.info[*].ip` (little-endian uint32, e.g. 192.168.255.133 → 2248124608) is the FTP destination IP BambuStudio uses for "Send to Printer storage" — it overrides anything else, including the URL hosts the rest of MQTT advertises. The bridge rewrites this to the VP's bind IP on cache, otherwise the slicer FTPs straight to the real printer and bypasses Bambuddy entirely (symptom: "Failed to send" with zero inbound FTP connections on the VP — debug-by-tcpdump if anyone hits it again). (3) `upgrade_state.sn` and any other nested-dict `sn` matching the target serial are rewritten to the VP serial; AMS-hardware serials (`n3f/0.sn` etc.) are left alone — those identify physical AMS units, not the device. (4) `ipcam.rtsp_url` is left unchanged: BambuStudio overrides the URL host with the device IP it bound on (the VP), so the slicer hits the VP's :322 RTSPS port — not the printer's directly. (5) For the slicer's RTSPS to reach the printer, the VP gets a raw `TCPProxy` on `<bind_ip>:322 → <printer_ip>:322` (same approach proxy mode uses; `cap_net_bind_service` was already in the systemd unit for FTP :990). (6) `extrusion_cali_get` is forwarded — answering it locally hides the user's stored k-profiles. **Setup nuance for camera:** because the slicer authenticates against the printer's RTSPS with whatever access code is in its profile, the VP's access code must match the target printer's access code for the camera path to authenticate. This is a one-time configuration step (Settings → Virtual Printer → set access code = target printer's LAN code, then re-add the VP in Bambu Studio / Orca Slicer). MQTT and FTP work either way; only camera needs the match because RTSPS auth happens between the slicer and the real printer's broker. **Tested e2e** with both BambuStudio and OrcaSlicer against H2D (dual-nozzle, AMS 2 Pro + AMS HT) and X1C (single-nozzle, AMS) across all three non-proxy modes (Immediate / Review / Print Queue) — sync, send, k-profile lookup, AMS configuration from slicer, and live camera all work. **Files:** new `backend/app/services/virtual_printer/mqtt_bridge.py` (caches push_status / get_version, forwards slicer commands, fans out command responses, rewrites identity fields including `net.info[*].ip` LE uint32); `bambu_mqtt.py` gains `register_raw_message_handler` / `unregister_raw_message_handler` / `publish_raw` so the bridge can subscribe to Bambuddy's existing per-printer paho subscription without opening a second session; `mqtt_server.py` switches `_send_status_report` and `_send_version_response` to cached-as-base when the bridge has data, falls back to the original synthetic stubs otherwise; `manager.py` wires the bridge + a raw `TCPProxy` for RTSPS into `start_server` for non-proxy modes whenever a target printer is configured. 25 new tests in `test_vp_mqtt_bridge.py` pin the contract: lifecycle, push_status caching, serial / IP rewriting, get_version-modules cache, selective fan-out (only command responses, never push_status itself), wire format must use `indent=4`, routing of slicer-issued commands (project_file / gcode_file local; everything else forwarded), and the IP-encoding helper against captures from real H2D pushes. Proxy mode is untouched — `SlicerProxyManager` still owns its own MQTT/FTP/RTSP/Bind/Aux proxies in proxy mode and never instantiates `SimpleMQTTServer` or `MQTTBridge`.
- **AMS slot Load / Unload from the printer card** ([#891](https://github.com/maziggy/bambuddy/issues/891), reported by @NNeerr00, +1 from @cadtoolbox) — The MQTT primitives for "load filament from a tray" and "unload the currently loaded tray" already existed in `bambu_mqtt.py` (reverse-engineered from BambuStudio captures, including the H2D dual-extruder right-external case captured fresh during this work) but were unused — there was no HTTP route and no UI. Net effect: every Load / Unload had to happen on the printer touchscreen, and external-spool users on dual-nozzle H2D had no way to drive Ext-R from the desktop at all. **Backend:** new `POST /printers/{id}/ams/load?tray_id={int}` and `POST /printers/{id}/ams/unload`, both gated on `Permission.PRINTERS_CONTROL`. The load route validates `tray_id ∈ {0..15, 254, 255}` (AMS slots, single-external/Ext-L, Ext-R respectively) and returns a human-readable target in the success message ("AMS 0 slot 1", "external spool", "Ext-R") so the UI toast tells the user which spool the printer is now feeding from. **MQTT primitive update:** `ams_load_filament` gains a third encoding branch for `tray_id=255` matching the BambuStudio capture verbatim — `ams_id=255, slot_id=0` (the right-extruder index, **not** a slot index — Bambu's load command on dual-extruder externals encodes the destination extruder, not the source slot), `target=255`, and `curr_temp = tar_temp = right-nozzle temp` (read from `state.temperatures["nozzle_2"]`, falling back to 215 °C if the right nozzle is cold or unknown — the printer rejects nonsensical temps, so a warm fallback is safer than `-1`). The existing `tray_id=254` branch is preserved verbatim (`slot_id=254, curr/tar=-1`) since that came from a single-extruder capture and is known to work; no risk of regression on existing single-external setups. **UI:** the existing AMS slot popover (the one with "Re-read RFID") gains two new entries — "Load" (posts `tray_id = ams.id * 4 + slotIdx`) and "Unload" (no params, global on the currently-loaded slot). The external spool slot — which had no popover at all before — gets one with the same Load + Unload entries, and on dual-nozzle H2D each external slot (Ext-L tray_id=254, Ext-R tray_id=255) drives its own extruder. The menu is hidden while `state === 'RUNNING'` (parallels the existing RFID re-read gating). **i18n:** `printers.ams.load`, `printers.ams.unload`, plus four new toast strings (`loadInitiated`, `unloadInitiated`, `failedToLoad`, `failedToUnload`) added to all 8 locales — English fully translated, German fully translated, the other 6 locales seeded with English copy pending native translation (matches the project's existing flow for newly-added user-facing features). 16 new tests pin the contract: 5 unit tests in `test_bambu_mqtt.py::TestAmsLoadFilamentEncoding` (AMS slot encoding, Ext-L preserves legacy capture, Ext-R uses the new captured shape with actual right-nozzle temp, Ext-R falls back to 215 °C when cold, disconnected client doesn't publish); 11 integration tests in `test_printers_api.py::TestAMSLoadUnloadAPI` (load: invalid tray_id 400, not-found 404, not-connected 400, AMS slot success with derived `ams_id*4+slot` math, Ext-L success, Ext-R success, MQTT failure 500; unload: not-found, not-connected, success, MQTT failure 500); 4 frontend tests in `PrintersPageAmsLoadUnload.test.tsx` (Load posts the right tray_id, Unload posts with no params, menu hidden while RUNNING, external spool's tray_id=254 round-trips through the route).
- **API keys can read Bambu Cloud presets on the owner's behalf** ([#1182](https://github.com/maziggy/bambuddy/issues/1182), reported by @turulix) — Tim is building a fully automated headless slicing pipeline against Bambuddy's API and hit the wall flagged in the previous round of cloud-auth work ([#665](https://github.com/maziggy/bambuddy/issues/665)): `/cloud/*` routes resolve `cloud_token` per-user from `User.cloud_token`, but the auth gate (`require_permission_if_auth_enabled`, `auth.py:856`) returned `None` for API-keyed requests, so the route fell back to the global `Settings`-table token, which only carries a value in *auth-disabled* deployments. Net effect on auth-enabled deployments: API keys reached the gate just fine, then `/cloud/filaments` always saw `user=None`, called `get_stored_token(db, None)` against an empty Settings table, and returned 401 / empty results — no path to read the slicer presets, filament catalogue, or device list that a CLI workflow needs. The data model treated API keys as standalone tokens with no owner (`APIKey` had `id`, `name`, `key_hash`, scope flags, and `printer_ids` — no `user_id`), so even if the gate wanted to delegate the cloud lookup, there was no User to delegate to. **The fix:** make API keys carry an owner, route /cloud/* lookups through that owner, and gate the new capability behind an explicit opt-in scope so existing automation doesn't gain cloud-read access on upgrade. Concretely: (1) `APIKey` gains `user_id` (FK to `users.id`, ON DELETE CASCADE — Postgres enforces, SQLite plus an explicit `DELETE FROM api_keys WHERE user_id = ?` in the user-delete route since SQLite ships FK enforcement off; the project's existing pattern at `users.py:397-406` for `created_by_id` cleanup) and `can_access_cloud` (BOOLEAN DEFAULT 0 — opt-in, never set on legacy rows). (2) The auth gate now returns the owner User when it validates an API key with `user_id` set, so `/cloud/*` routes naturally resolve `user.cloud_token` the same way they do for JWT-authed sessions. Permission semantics are preserved — API keys still bypass the per-route permission check (their scopes live on the row itself), the User return is *only* so cloud-aware routes can read per-user state. Legacy ownerless keys (`user_id IS NULL`) keep returning None, stay anonymous, and continue working against every non-cloud route exactly as before. (3) A router-level dependency on the `/cloud/*` `APIRouter` enforces three independent fences for API-keyed callers: `user_id IS NOT NULL` (legacy keys → 401 with "recreate it from Settings → API Keys" — explicit recreate path rather than silently degrading), `can_access_cloud=True` (otherwise 403 with "Enable 'Allow cloud access' on the key"), and `build_authenticated_cloud` returning a service (otherwise 401 with the existing token-not-set error — unchanged for JWT flow). The router-level dep duplicates the API-key validation done by the regular auth gate (router-level deps run before route-level deps in FastAPI, so `request.state` isn't populated yet) — the cost is one extra `SELECT FROM api_keys` per cloud request, bounded and cheap with the `key_prefix` index. (4) The create route stamps `user_id = current_user.id` from the creator and rejects `can_access_cloud=True` when auth is disabled (no per-user `cloud_token` storage exists in that mode — fail loudly at create time rather than silently producing a non-functional key). PATCH route rejects flipping `can_access_cloud` to True on a legacy ownerless key for the same reason — force recreate. (5) `APIKeyResponse` exposes `user_id` so the UI can show ownership at a glance: a "Cloud" badge for cloud-enabled keys and a "Legacy" badge with hover tooltip ("Created before per-user ownership; recreate to use cloud access") for ownerless rows. The form gains an "Allow cloud access" checkbox, default off. **Migration:** two idempotent `ALTER TABLE api_keys ADD COLUMN` (`user_id INTEGER REFERENCES users(id) ON DELETE CASCADE` and `can_access_cloud BOOLEAN DEFAULT 0`) plus an index on `user_id` for the auth-gate's owner→keys lookup that runs on every API-keyed request. **i18n:** 5 new keys (`settings.cloudAccess`, `settings.cloudAccessDescription`, `settings.cloudBadge`, `settings.legacyKey`, `settings.legacyKeyTooltip`) added to all 8 locales — English fully translated, German fully translated, the other 6 locales seeded with English copies pending native translation (matches the project's existing flow for newly-added user-facing features). 9 backend integration tests in `test_api_key_cloud_access.py`: create stamps owner + cloud flag, defaults off when not asked for, rejected when auth disabled (no per-user storage), PATCH rejected on legacy keys; cloud router rejects legacy keys with the recreate copy, rejects owned-but-no-cloud-flag keys with the enable-cloud-access copy, lets owned-and-flagged keys through with owner's `cloud_token` in the response, JWT callers unaffected (gate is no-op for non-API-keyed); user-delete CASCADEs the API keys via the explicit DELETE in the route. 2 frontend SettingsPage tests pin the badge rendering matrix (Cloud badge present on `can_access_cloud=true`, Legacy badge present on `user_id=null`, neither rendered on a normal owned non-cloud key) and the create-form contract (toggling "Allow cloud access" results in `can_access_cloud=true` in the POST body). Permission semantics for the new fence are the only behavioural change for existing API keys: keys created before this release become "legacy" rows and are rejected at /cloud/* with the recreate message; every other endpoint they were used against — queue, status, control — is untouched.

- **Home Assistant addon detection — Settings → Updates and the in-app update banner now defer to the HA Supervisor** ([#1167](https://github.com/maziggy/bambuddy/issues/1167), reported by @Spegeli) — Bambuddy already shipped `HA_URL`/`HA_TOKEN` env-var support specifically labelled "for HA Add-on deployments" ([#283](https://github.com/maziggy/bambuddy/issues/283)) and a community-maintained HA addon (`hobbypunk90/homeassistant-addon-bambuddy`) exists upstream, so an HA-supervised installation is a real first-class deployment shape. Until now though, the update UI didn't know about it: HA addon users got the same "Update available!" banner as everyone else and, if they clicked through to Settings, saw the docker-compose snippet ("`docker compose pull && docker compose up -d`") which they cannot run from inside an HA addon container — that's the Supervisor's job. Detection uses the canonical signal: HA Supervisor injects `SUPERVISOR_TOKEN` into every addon container, and that variable is not set in any other environment. A new `_is_ha_addon()` helper in `backend/app/api/routes/updates.py` flips a request-level boolean which `/updates/check` surfaces as `is_ha_addon: bool` + an extended `update_method: 'git' | 'docker' | 'ha_addon'` enum. The check is checked **before** Docker on `/updates/apply` because HA addons *are* Docker containers — checking docker first would mis-classify them and serve the wrong message; the response also keeps `is_docker: true` alongside `is_ha_addon: true` so older frontend bundles still hit a managed-deployment branch (degrading to the Docker UX) instead of rendering an in-app Install button that can't work. Frontend branches identically: `SettingsPage.tsx`'s update card checks `is_ha_addon` first and renders "Updates are managed by the Home Assistant Supervisor. Open Settings → Add-ons → Bambuddy in Home Assistant to install the new version." in place of the docker-compose hint; `Layout.tsx`'s update banner is suppressed entirely for HA addons since the HA Supervisor's own update notification already surfaces the new version natively in the HA UI and a duplicate Bambuddy banner would just be noise that links to a page that says "go to HA". Plain Docker deployments are unaffected — the existing docker-compose hint and the in-app banner still render the same way they did. Localised across all 8 UI languages (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW) with full translations of the new `settings.updateViaHomeAssistant` string. 6 new tests pin the contract: 3 backend unit tests for `_is_ha_addon()` (env var present → true, absent → false, empty string treated as unset to guard against shells that export it empty), 1 backend integration test for the HA-precedes-Docker rejection on `/updates/apply` (asserts the message says "Home Assistant" and not "Docker Compose"), 2 backend integration tests for `/updates/check` covering the HA-addon branch (`update_method == "ha_addon"`, both flags true) and the plain-Docker branch (`is_ha_addon: false`, `update_method == "docker"`); 2 frontend SettingsPage tests pin the mutually-exclusive UI rendering (HA branch shows the HA copy and not the docker-compose snippet; Docker branch shows the snippet and not the HA copy, neither shows the Install button); 2 frontend Layout tests pin the banner suppression for HA and its retention for plain Docker.

- **OIDC auto-created users now get readable usernames and land in a configurable group** ([#1173](https://github.com/maziggy/bambuddy/issues/1173)) — Two improvements to the OIDC auto-create flow: (1) **Username derivation**: Bambuddy now derives the username from `preferred_username`, then `name`, before falling back to the opaque `provider_sub[:30]`. Each candidate is sanitized independently — alphanumeric plus `./-/_`, whitespace collapsed, deduplication suffix appended on collision — so a value that strips to empty (e.g. `"!!!"`) correctly falls through to the next option rather than silently producing `"oidcuser"`. (2) **Default group**: each OIDC provider gains a `default_group_id` field. When set, auto-created users are placed in that group; when unset, the existing "Viewers" fallback is preserved, so behaviour is unchanged for existing deployments. The column is nullable with `ON DELETE SET NULL`; SQLite does not enforce FK constraints here, so a deleted configured group falls through to Viewers at runtime. `default_group_id` is validated on create/update (422 on a non-existent group). Exposed in the OIDC settings form as a group dropdown. **Limitation:** to clear a configured default group, delete the group or select a different one — explicit reset-to-null is not currently supported.

- **Filament Track Switch (FTS) support — print modal filament dropdown is no longer empty when an X2D / H2D has the FTS accessory installed** ([#1162](https://github.com/maziggy/bambuddy/issues/1162), reported by @mkavalecz) — When the FTS accessory is installed the printer's MQTT changes one nibble of the per-AMS `info` bitmask: bits 8-11 flip from a fixed extruder ID (0x0 / 0x1) to `0xE` ("uninitialized"), because the AMS is no longer wired to a single nozzle — the FTS dynamically routes any slot to either extruder. Bambuddy's MQTT parser already skipped 0xE entries when building `ams_extruder_map` (matching BambuStudio's reading for boot-time transient state), so with the FTS installed the map ended up empty and the print modal's filament dropdown — which filters by `extruderId === nozzle_id` to prevent cross-nozzle assignment ("position of left hotend is abnormal" failures) — filtered out *every* loaded slot. Net effect: empty Filament Mapping dropdown on every dual-nozzle print with the FTS, even when the AMS was fully loaded with the right material. Detection comes from a new MQTT field — `print.device.fila_switch` — which is non-null only when the accessory is installed; it carries the routing topology as two arrays: `in[track] = currently fed slot (-1 = empty)` and `out[track] = extruder this track terminates at`. The fix surfaces this through a new `FilaSwitchState` dataclass on `PrinterState` (`installed`, `in_slots`, `out_extruders`, `stat`, `info`) and the equivalent `FilaSwitchResponse` Pydantic schema on the `GET /printers/{id}/status` route. Frontend (`useFilamentMapping.ts` + `FilamentMapping.tsx`) skips the per-extruder filter when `printerStatus.fila_switch?.installed === true` so any compatible AMS slot can satisfy any nozzle's filament requirement, since the FTS handles the routing. Slots currently fed into a track also get a routing badge in the dropdown — `[L]` or `[R]` — so the user can tell at a glance which slot the FTS is currently routing where (idle slots get no badge: they can be routed to either extruder on demand). The hard "no cross-nozzle assignment" filter on real dual-nozzle printers without the FTS stays untouched (still trips the same way it always has — `fila_switch == null` keeps the existing behaviour). 4 backend tests in `test_bambu_mqtt.py::TestFilamentTrackSwitchDetection` (default-not-installed, detect-from-MQTT-using-the-reporter's-bundle, no-fila_switch-field-stays-not-installed, missing-in-out-arrays-don't-crash) and 2 frontend tests in `useFilamentMapping.test.ts` (FTS-active drops the nozzle filter; explicit `fila_switch: null` keeps the filter applied). Upstream fila_switch payloads with anything other than the documented shape are tolerated — `installed` flips on the *presence* of the field, the routing arrays default to empty lists if missing, and the dropdown skips the badge for slots not currently in `in_slots`.

### Fixed
- **Docker permission errors on `/app/data/virtual_printer` and similar paths — root-owned volumes / bind-mount sources no longer break virtual printer setup** ([#1211](https://github.com/maziggy/bambuddy/issues/1211) follow-up; same shape as multiple previous user reports) — Two related failure modes have been biting Docker users repeatedly: (1) Docker named volumes are created by the daemon as `root:root` and the previous `chmod 777 /app/data` Dockerfile workaround only covered the named-volume root, so subdirs Bambuddy creates at runtime (`virtual_printer/uploads`, `virtual_printer/certs`, etc.) inherited the wrong ownership when the container ran as `1000:1000`; (2) the shipped `docker-compose.yml` ships `./virtual_printer:/app/data/virtual_printer` uncommented, and dockerd creates a missing bind-mount source on the host as root before the container starts — leaving the host directory unwritable by uid 1000 inside the container even though the named volume above it had the chmod-777 workaround. Symptom either way: `[Errno 13] Permission denied: '/app/data/virtual_printer/uploads'`, no virtual printer ever starts, "VP doesn't work" support reports follow. **Fix:** new `deploy/docker-entrypoint.sh` runs as root, normalises ownership of `/app/data` and `/app/logs` (and `/app/data/virtual_printer` when bind-mounted) to `PUID:PGID` (default `1000:1000`, overridable via env), then drops to that uid via `gosu` before exec'ing uvicorn. The chown is gated behind a top-level ownership check so subsequent restarts skip the recursive traversal entirely (no multi-second startup penalty on multi-GB archive dirs). A sentinel `.bambuddy` file in each data path prevents Docker from re-syncing image directory metadata on every mount (otherwise empty volumes have their ownership reverted from the image on each restart, defeating the idempotency). When the container is started with an explicit `user:` directive in compose or `--user` on `docker run`, the entrypoint detects it isn't running as root and falls through to direct exec without modifying ownership — preserving compatibility with users who pin a specific uid. **Compose template changes:** the `user: "${PUID:-1000}:${PGID:-1000}"` line is removed (entrypoint owns privilege drop now); `PUID` / `PGID` env vars added with the same defaults; `./virtual_printer:/app/data/virtual_printer` bind mount commented out by default with a clearer explanation of when it's actually needed (only when sharing the VP CA certificate with a co-located native install, which most Docker-only users don't have). Existing users with that bind mount uncommented continue to work — the entrypoint chowns the host-side directory through the bind mount the first time it sees the wrong ownership, fixing #1211 specifically. **Tested end-to-end** against four scenarios on a clean rebuild: (a) named volume only with default PUID/PGID; (b) explicit `--user 1000:1000` override (entrypoint falls through); (c) custom `PUID=1500`; (d) legacy stale root-owned volume contents from a pre-fix install (gets normalised on first start). Idempotency verified: chown messages appear on first start, subsequent starts are silent.

- **Backup restore silently lost most data — settings reverted to defaults, ~most printers/archive rows missing** ([#1211](https://github.com/maziggy/bambuddy/issues/1211), reported by @Carter3DP; same shape as previously-closed [#668](https://github.com/maziggy/bambuddy/issues/668)) — Restoring a settings backup ZIP appeared to succeed but the user found their `energy_cost_per_kwh` reverted to the `0.15` default (defined in `main.py:3457`), 7 of 8 printers gone, 1 GB of archive files on disk but only 1 archive row in the database. #668 was closed in March without an actual fix — that user happened to make it work by rolling back to a stable release, which masked the bug; same shape resurfaces here on a single (consistent) version. **Cause:** the live database runs in WAL mode (`PRAGMA journal_mode = WAL` in `database.py:19`). The original restore endpoint used `shutil.copy2(backup_db, db_path)` after `engine.dispose()`. Two things conspired to make this unsafe: (1) anything the fresh container wrote between startup and the restore call — `seed_default_groups`, `init_db()` migrations, background heartbeat writes — sits in `bambuddy.db-wal` with valid checksums, and `engine.dispose()` doesn't checkpoint it; (2) FastAPI's dependency injection keeps the route handler's own `db: AsyncSession = Depends(get_db)` session checked out across `engine.dispose()` (per SQLAlchemy docs, dispose only closes pooled — not checked-out — connections), so the WAL inode is held open through the whole restore. After `shutil.copy2` rewrote the main DB inode in place, SQLite's WAL recovery on the next `init_db()` happily re-applied the stale frames on top of the restored content, partially clobbering it with fresh-install state. Initial fix attempt of "delete the WAL/SHM/journal sidecars before the copy" turned out to be insufficient — verified experimentally that the still-open request session reads the unlinked sidecars via held fds and bleeds the WAL state back into the new file when it eventually closes. **Real fix:** replace the file copy with SQLite's online backup API (`src_conn.backup(dst_conn)`). The page-by-page protocol opens both DBs as proper SQLite connections, acquires the right locks, and routes new pages through the destination's own WAL — concurrent open sessions see their own transactional snapshot until they close (transaction isolation) but can't corrupt the restored state. Verified via 6 regression tests in `backend/tests/unit/test_restore_sqlite_wal_safety.py`: the buggy `shutil.copy2` path is pinned (the test asserts the bug *manifests* under the un-checkpointed-WAL condition, so a future "small simplification" can't silently re-introduce it); the production `src_conn.backup(dst_conn)` path returns the user's restored values exactly under the same bug condition; the no-WAL-frames case (fresh container, restore as the very first action) round-trips cleanly; and the page-protocol parametrised test runs at 1, 100, and 1000-page DB sizes so a regression at any one size surfaces. PostgreSQL path (`_import_sqlite_to_postgres`) is unchanged — that's row-by-row already and was never affected.

- **`formatTimeOnly` tests failed under non-`:`-separator locales** ([#1213](https://github.com/maziggy/bambuddy/issues/1213), reported by @maugsburger) — Running the frontend test suite under `LC_ALL=en_DK.UTF-8` (or any locale whose `toLocaleTimeString` uses a separator other than `:`) failed two tests in `frontend/src/__tests__/utils/date.test.ts`: `formats time with 12h format` (expected `02.30 pm` to match `/2:30|02:30/`) and `formats time with 24h format` (expected `14.30` to contain `14:30`). The implementation is correct — `formatTimeOnly` calls `date.toLocaleTimeString([], …)` which by design respects the user's locale, so a Danish-English user genuinely should see `02.30 pm` in the UI. The tests just hard-coded the `:` separator. **Fix:** test assertions now use `\D+` (any non-digit, one or more) for the separator: `expect(result).toMatch(/\b0?2\D+30\b/)` and `expect(result).toMatch(/\b14\D+30\b/)`. Tests the actual contract — "the function returns hours and minutes, separated somehow" — without coupling to a specific separator that varies by locale (en_DK uses `.`, some en_* locales use a narrow no-break space at U+202F, most others use `:`). Verified passing under `en_DK.UTF-8`, `en_US.UTF-8`, and `de_DE.UTF-8`. Audited every other `toLocaleTimeString`/`toLocaleString` call site in the test suite — no other places hard-code separator characters; `formatETA`, `formatDateInput` etc. assert via `toBeTruthy()` or check translated content.

- **SpoolBuddy kiosk screen-blank timeout setting was ignored after the first save** (reported by maziggy) — Picking a new "Screen Blank Timeout" in SpoolBuddy Settings → Display didn't change the actual blanking behaviour: whatever value was active when the kiosk last booted continued to fire — a user who started with the 10 m preset and then switched to 1 m, 5 m, or "Off" still saw the screen blank at 10 m forever. Cause: blanking is driven by `swayidle`, started once by `spoolbuddy/install/spoolbuddy-idle.sh` at labwc autostart with the timeout passed as a command-line argument (`swayidle -w timeout $T 'wlopm --off' resume 'wlopm --on'`). The script fetched `blank_timeout` from the backend exactly once at startup and `swayidle` has no runtime control surface for changing its timeout. The Python daemon's `display.set_blank_timeout()` updated an in-memory variable on the daemon side that was only used for daemon-side idle bookkeeping (`tick()` log-line) and never reached `swayidle`, so UI changes were silently discarded until the next kiosk restart. Documented as such in the daemon's docstring (`display_control.py:5`: *"swayidle is the sole authority on screen blanking"*) — the architecture predicted the bug, the UX never matched. **Fix:** the wake FIFO at `/tmp/spoolbuddy-wake` now carries a second message in addition to `wake`: `reload-timeout N`. The daemon writes it whenever `set_blank_timeout()` is called with a value that differs from the current one (the very first call is suppressed because the watchdog already fetched the same value at its own startup — signalling there would just thrash `swayidle` on every cold start). The watchdog script's FIFO loop is restructured around `start_swayidle` / `stop_swayidle` helpers and a single `case` statement that dispatches on the message: `wake` → `wlopm --on` + arm a re-blank at the *current* timeout; `reload-timeout N` → kill the running `swayidle`, set `TIMEOUT=$N`, restart `swayidle`, and `wlopm --on` so the user sees the change took effect even if the screen was already blanked. The script de-dupes too — a `reload-timeout N` whose `N` matches the current value is a no-op, so the daemon's local de-dupe and the script's de-dupe both guard against thrash. Going from any positive timeout to `0` ("Off") correctly stops `swayidle` and never restarts it, going from `0` to a positive value starts a fresh `swayidle` — both work without a kiosk restart. The script's main loop opens the FIFO read+write (`exec 3<>"$WAKE_FIFO"`) so the bash `read` never sees EOF when the daemon momentarily disconnects between writes (without that, the loop would exit the first time the daemon closed its write end). A `cleanup` trap on `TERM/INT/HUP` stops `swayidle`, removes the FIFO, and exits cleanly. 7 new tests in `spoolbuddy/tests/test_display_control.py::TestDisplayControlFifoMessages` pin the daemon side of the protocol against a real FIFO in `tmp_path`: `wake()` writes the literal `wake\n` line; first `set_blank_timeout` is suppressed (script already has the right value); subsequent change emits `reload-timeout N\n`; identical-value calls don't signal; transitioning to `0` emits `reload-timeout 0\n` (covers "user picks Off after enabling"); negative inputs are clamped to `0` in the signal payload; missing-FIFO writes are silent no-ops (kiosk-not-running case). Also handles the SpoolBuddy `0` schema default — the first `set_blank_timeout(0)` call from a fresh daemon doesn't signal (init suppressed) so no spurious thrash on a never-configured device.

- **Archive 3MFs (and library file bytes) silently deleted from disk on every print completion** ([#1212](https://github.com/bambuman/bambuddy/issues/1212), reported by @abbasegbeyemi; matches private "file disappeared overnight" reports) — Reprint and View G-code on a freshly-completed archive returned 404 with no log line explaining why; the DB row was intact, the archive grid kept showing the entry, but `archive.file_path` pointed at a path that no longer existed on disk. Same shape independently reported by a daily-build user whose `.gcode.3mf` "disappeared by itself overnight" between Saturday's print and Monday morning's reprint attempt. Root cause was a regression introduced by [#1166](https://github.com/maziggy/bambuddy/issues/1166)'s cover-cache pre-population: the dispatch sites in `background_dispatch.py:692`, `background_dispatch.py:896`, and `print_scheduler.py:1897` started caching the **live archive copy** (and library file bytes for the Direct-Print flow) in the shared 3MF download cache so the `/cover` endpoint could skip a redundant FTP transfer to the printer mid-print. The cache itself was originally designed for transient downloads under `archive_dir/temp/` and `clear_3mf_cache(printer_id, delete_files=True)` — called from `on_print_complete` to keep that temp dir from accumulating — happily `unlink()`'d every cached path. Pre-#1166 every cached path was a temp file, so deletion was correct. Post-#1166 the cleanup was destroying user data: every print → archive 3mf cached → on print complete `clear_3mf_cache` walks the cache → `path.unlink()` on the actual archive copy. The `Path.exists()` guard inside `_maybe_unlink` masked the failure: the file existed at unlink time, so no exception, no warning, just silent destruction. The DB row remained, so the UI listing didn't change — only when the user tried to *act* on the archive (reprint / view-gcode / re-export) did the missing file surface as a 404. Affected every daily build since [`889c8bd8`](https://github.com/bambuman/bambuddy/commit/889c8bd8) (Apr 29). **Fix:** `clear_3mf_cache._maybe_unlink` in `backend/app/services/bambu_ftp.py` now refuses to `unlink()` any path outside `archive_dir/temp` — the cache dict is still cleared either way (so re-cache logic continues to work and the cover endpoint still hits a fresh path on the next print), only the on-disk delete is gated. Persistent locations — `archive/<printer_id>/...`, `archive/unassigned/...` (VP-archived prints with `printer_id=None`), `library_files/...`, and any `is_external` library mount — survive intact. The dispatch sites that cache those paths are unchanged: it's correct for `/cover` to read straight from the live archive copy and avoid the redundant 36 MB FTP transfer; the only bug was the cleanup branch treating all cached paths as transient. Regression test `test_clear_does_not_delete_persistent_files` in `test_bambu_ftp.py` pins the contract end-to-end: an archive 3mf at `archive/1/.../...gcode.3mf`, a library 3mf at `library_files/...`, and a temp 3mf at `archive/temp/...` are all cached for the same printer; after `clear_3mf_cache(1)` runs, all three cache entries are dropped from the dict (so the cache state is consistent), but only the temp file is unlinked from disk — the archive and library files still exist. Two existing cache tests (`test_clear_by_printer_scoped`, `test_clear_without_deleting_files`) updated to put their fixtures under `archive_dir/temp` since that's now the only path the cleanup will touch. **Damage:** users on daily builds since Apr 29 with a `print → wait for completion → reprint or view-3mf later` workflow have been silently losing archive copies. Recovery for individual users: re-import the source 3mf from your slicer / NAS, or re-archive from the printer's FTP if the file is still there. Going forward the bytes are safe.

- **MakerWorld P2S 3MFs failed to slice with "Param values in 3mf/config error: -1 not in range"** ([#1201](https://github.com/maziggy/bambuddy/issues/1201), reported by @inorichi) — Slicing any MakerWorld model sliced for the P2S (e.g. `https://makerworld.com/en/models/1958872`) bombed with `Slicer process failed (exit code 238)` and stderr listing `raft_first_layer_expansion: -1 not in range [0.0, 3.4e+38]` and `tree_support_wall_count: -1 not in range [0.0, 2.0]`. Root cause: BambuStudio writes `"-1"` into `Metadata/project_settings.config` for fields the user wants inherited from the parent process preset — the GUI handles this internally, but the headless CLI (orca-slicer-api / bambu-studio-api sidecar) runs `StaticPrintConfig`'s range validator against the embedded settings *before* the `--load-settings` overrides apply, so the sentinel `"-1"` trips the field's lower-bound check and the CLI exits non-zero before our profile triplet is ever consulted. The `slice_with_profiles` path failed; the fallback to `slice_without_profiles` (which uses embedded settings only) also failed because it reads the same `project_settings.config` and the same validator runs there too. Earlier in the codebase there's a `_strip_3mf_embedded_settings` function that tried to dodge this by removing the entire `project_settings.config` (plus `model_settings.config`, `slice_info.config`, `cut_information.xml`); that experiment was reverted because the strip broke `StaticPrintConfig` initialisation — silent exit-0, no `result.json`, no stderr, masked by the fallback retry which then produced wrong-printer output without telling anyone (the cautionary comment in `library.py:_run_slicer_with_fallback` records the lesson). **Fix is surgical:** new `_sanitize_project_settings_sentinels(zip_bytes)` opens the embedded config, removes only allowlisted keys when their value is exactly `"-1"`, and re-zips. Allowlist (`_PROJECT_SETTINGS_SENTINEL_KEYS`) starts with the two from this report (`raft_first_layer_expansion`, `tree_support_wall_count`) plus `prime_tower_brim_width` (a known sentinel cited in the strip-experiment comment block from earlier reports). Other fields — including non-allowlisted keys that happen to hold `"-1"` (e.g. `z_offset` set to `-1` deliberately by a user) — are left untouched, so a blanket "-1 strip" can't silently corrupt legitimate negative values. The sanitiser runs before *both* the profile-driven path and the embedded-settings fallback, since both fail on the same input. Defensive fallbacks: returns the original bytes unchanged when the input isn't a valid zip, doesn't contain `project_settings.config`, has no allowlisted sentinels present, the JSON is malformed, or the config root isn't a dict — so the caller can pass the result on without further checks. Geometry, thumbnails, color, multi-part data, and every other zip entry round-trip byte-identical (the previous full-strip experiment's failure mode can't reoccur). 13 new unit tests in `test_project_settings_sentinel_sanitiser.py` pin the contract: each allowlisted key removed when value is `"-1"` (parametrised across the allowlist); multiple sentinels removed at once; allowlisted key with legitimate non-sentinel value (`"0"`) preserved; non-allowlisted key holding `"-1"` (`z_offset`) preserved; identity return when nothing needs sanitising; array-form values (per-filament/per-extruder lists) left alone (v1 handles scalar strings only, expand later if needed); other zip entries (model_settings.config, slice_info.config, _rels metadata, geometry) all preserved with byte-identical content; non-zip input passes through; missing `project_settings.config` passes through; malformed JSON passes through; non-dict JSON root passes through. **Adding new sentinel keys:** if a future report surfaces another field name in the slicer's `<field>: -1 not in range [...]` error, add the field to `_PROJECT_SETTINGS_SENTINEL_KEYS` — the rest of the code stays unchanged.

- **Archive created with wrong plate metadata when consecutive plates of the same model are printed back-to-back** ([#1204](https://github.com/maziggy/bambuddy/issues/1204), reported by @BurntOutHylian) — Print Plate 2 of any multi-plate project, let it complete, then immediately print Plate 1: the resulting archive was named "MyModel - Plate 2" with Plate 2's filament slots and slicer estimate, even though Plate 1 was the print actually running. Root cause was an MQTT lag in the `print_start` data: the trigger fires on a `gcode_file` change (`bambu_mqtt.py:2781-2786` — the field carrying `/data/Metadata/plate_N.gcode`, which is plate-specific and always fresh), but `subtask_name` (model-level, e.g. "MyModel - Plate 2") can still echo the previous job in the same MQTT batch. The FTP candidate list in `main.py:1974` is built from `subtask_name` first, so the previous Plate 2 upload — still resident on the printer's FTP from the just-completed print — got picked up and fed into archive creation. The 3MF parser then read `_plate_index=2` from the wrong file's `slice_info.config` and locked Plate 2's name + estimate + per-slot filament data into the row at creation, with no follow-up to correct. Reporter @BurntOutHylian's diagnosis nailed it: the parser already extracts `_plate_index` from inside the 3MF (`archive.py:154`), and `parse_plate_id()` (`printer_manager.py:678`) already extracts the plate from `gcode_file` — those two values just weren't being compared. **Fix:** new helpers `peek_plate_index_in_3mf()` (cheap zip read of `Metadata/slice_info.config` only, returning the plate index) and `swap_plate_suffix()` (rewrites trailing " - Plate N" or "_plate_N" — both forms appear in real subtask_names, see `test_print_start_expected_promotion`) in `archive.py`. After a successful FTP download in `_handle_print_start`, the new validation block in `main.py` peeks the downloaded 3MF's plate index, compares against `parse_plate_id(filename)`, and on mismatch retries the FTP fetch with a corrected `subtask_name`. If the retry finds a 3MF whose plate matches, the wrong file is dropped and the corrected one is used — archive name + estimate + slots all reflect the actual plate. If the retry can't find a matching file (or no swap is possible because `subtask_name` had no plate suffix to swap), the wrong 3MF is dropped and the existing no-3MF fallback (`main.py:2155`) creates an archive without metadata; the stale `subtask_name` is overridden to the corrected one (or cleared so `filename` wins) so the fallback's `print_name` at least reflects the right plate rather than locking in a misleading name. The validation only fires when `parse_plate_id(filename)` returns a value, so single-plate / non-Bambu / cloud-named jobs are unaffected. **Defence in depth:** the cache eviction is implicit — `temp_path.unlink()` makes the wrong-file cache entry self-clean on next access via the existing `get_cached_3mf` evict-on-miss path (`bambu_ftp.py:660-664`); no separate cache invalidation needed. 17 new unit tests in `test_archive_plate_validation.py` pin the helpers: `peek_plate_index_in_3mf` returns the index for a valid 3MF, None for missing slice_info, None for missing index metadata, None for non-zip files, None for missing files, None for non-integer index values; `swap_plate_suffix` handles the spaced "Plate N" form (capitalised + lowercase + tight-hyphen), the underscored "_plate_N" form (the `Box3.0_(2)_plate_5` case from the existing fixture), case-insensitive matching, returns None for names without a recognised suffix, returns None for None input, and preserves separator casing so the corrected name matches what BambuStudio actually uploaded.

- **SpoolBuddy kiosk screen never blanked while a load cell was producing noisy readings** (reported during user testing) — A noisy HX711 / load-cell mount that bounced the reported weight by ≥50 g around its midpoint kept the kiosk display permanently lit. The wake gate in `spoolbuddy/daemon/main.py:scale_poll_loop` (`WAKE_THRESHOLD = 50`) checked the absolute change against `last_wake_grams` and, on every trip, advanced `last_wake_grams` to the new noisy reading — so the next bounce back also exceeded the threshold, fired `display.wake()` again, and the screen never stayed off long enough for swayidle's `wlopm --off HDMI-A-1` to mean anything. Symptom in the field: ~3–30 s between `Wake signal sent via FIFO` log lines, exactly correlated with the bigger noise spikes, screen flicker-blanking and immediately turning back on. Diagnosis from a real device's `journalctl -u spoolbuddy.service`: `scale/reading` POSTs every ~1 s (REPORT_THRESHOLD=2 g, so the load cell was reporting ≥2 g changes constantly) interleaved with periodic wake signals. **Fix**: the wake gate now requires the scale's `stable` flag (True only when consecutive readings agree within 2 g over a 1 s window — already produced by `ScaleReader.read()` and previously only forwarded as telemetry to the backend). Unstable noise can no longer fire wake AND can no longer poison `last_wake_grams`, since the threshold check + the assignment are both gated on `stable`. Real spool placements / removals produce a settled post-event reading and continue to wake the screen as intended. 3 new regression tests in `spoolbuddy/tests/test_main.py::TestScalePollLoopWakeGating`: noisy ±60 g unstable readings never wake (the original bug); a settled >50 g jump wakes; a noise burst between two settled readings doesn't poison `last_wake_grams` (asserts the second stable wake still fires from the *original* baseline rather than the noisy peak).

- **Print-complete notification reported the slicer's pre-print estimate instead of the actual elapsed time** ([#1198](https://github.com/maziggy/bambuddy/issues/1198), reported by @BurntOutHylian) — `_background_notifications` in `main.py:3434` built `archive_data` for the completion notification with `print_time_seconds` (the slicer's estimate parsed from the 3MF at archive creation), and `notification_service.py:909-910` then formatted that field straight into the `{{duration}}` template variable. Net effect: a print cancelled 2 minutes into a 3-hour estimate told the user "duration: 3h" — wrong by orders of magnitude for any cancellation, abort, slow first layer, or any print whose actual elapsed diverged from the slicer's guess. The companion field `actual_filament_grams` was already scaled by progress for partial prints (line 3445), so filament was right while time was wrong. The `print_start` notification uses a separate `{{estimated_time}}` variable (line 838), so `{{duration}}` semantically should always have meant "actual elapsed" — it was just being read from the wrong source. **Two-part fix:** **(1)** `main.py:3434` now computes `actual_time_seconds = int((archive.completed_at - archive.started_at).total_seconds())` from the persisted timestamps when both are present and the elapsed is positive, and adds it as a new key in `archive_data`; `notification_service.py:909-916` prefers `actual_time_seconds` and falls back to `print_time_seconds` only when timestamps weren't recorded (so the notification still has *something* if the elapsed can't be derived). **(2)** `main.py:3172` adds `"cancelled"` to the set of statuses that get `completed_at` set when `update_archive_status` runs — pre-fix only `completed`, `failed`, `aborted` got a timestamp, but `cancelled` (Bambuddy queue UI cancellation, distinct from touchscreen-aborts which already set `completed_at`) was deliberately excluded for reasons that no longer hold. Audited every `completed_at` consumer in backend (`archives.py:80, 333-337, 768-770, 723-731, 1722-1813`, `main.py:3229`, `projects.py:1475, 1489`) and frontend (`PrintersPage.tsx:2854`, `QueuePage.tsx:1053`, `StatsPage.tsx:902`); none rely on `completed_at IS NULL` to mean "this is a cancelled print" — the three explicit-status filters already restrict to `status == "completed"` and the rest are `completed_at or created_at` fallback expressions that gracefully accept either. Knock-on benefit: the statistics-totals aggregation at `archives.py:723-731` (which currently adds the *full* slicer estimate to the total when `completed_at IS NULL`) now adds the actual elapsed for cancelled prints too — a 2-minute cancellation contributes 2 minutes instead of 3 hours. Existing cancelled rows in the DB stay with `completed_at=NULL`; only new cancellations going forward get the timestamp. 3 new regression tests in `test_notification_service.py::TestNotificationVariableFallbacks` pin the contract: `{{duration}}` reflects `actual_time_seconds` when present (2m elapsed wins over 3h estimate), falls back to `print_time_seconds` when actual is missing (1h estimate still surfaced rather than "Unknown"), and surfaces "Unknown" when both are absent.

- **Frontend served behind a path-prefixed reverse proxy (e.g. `/bambuddy/` on Traefik / nginx / Cloudflare Tunnel) loaded a blank page** ([#1195](https://github.com/maziggy/bambuddy/issues/1195), reported by @Spegeli, follow-up to [#1167](https://github.com/maziggy/bambuddy/issues/1167)) — Vite's default `base: '/'` emits absolute asset URLs in the built `index.html` (`/assets/index-*.js`, `/assets/index-*.css`, `/manifest.json`, `/img/...`, `/sw-register.js`), which assumes the SPA is always served at the host root. Behind any path-prefixed reverse proxy — Traefik with a path prefix, nginx `location /bambuddy/`, Cloudflare Tunnel with path routing, Synology / Unraid reverse-proxy panels — the browser then requests those absolute paths from the host root, the proxy doesn't see them, and the upstream serves either a 404 or HTML for an unknown path with `Content-Type: text/plain`/`text/html`; the browser logs `Refused to apply style from '.../assets/index-*.css' because its MIME type is 'text/plain'` and renders a blank white page. Two-line fix: `frontend/vite.config.ts` sets `base: ''` so Vite's HTML transform rewrites every absolute asset reference to relative (`./assets/...`, `./manifest.json`, `./img/...`, `./sw-register.js`) — these resolve correctly against whatever subpath the document was served from. `frontend/public/sw-register.js` is a public-dir file Vite copies as-is, so its `navigator.serviceWorker.register('/sw.js')` call is changed to `register('sw.js')` (relative); the SW scope is automatically pinned to whatever subpath the document loaded from, which is exactly what every reverse-proxy-at-subpath user wants. Net effect: an `https://example.com/bambuddy/` deployment now loads correctly without any frontend rebuild on the user's side. **Out of scope for this change:** runtime API base detection — `API_BASE = '/api/v1'` in `frontend/src/api/client.ts` is still absolute, so API calls still go to the host root. This is intentional. The fix above closes the immediate "blank page" report; making the API base, React Router basename, PWA manifest scope, and service-worker scope all subpath-aware would mean rewriting how the SPA bootstraps and would touch PWA-install state, push-notification subscriptions, and deep-link reload semantics. The supported way to embed Bambuddy in Home Assistant remains the **Webpage panel + `TRUSTED_FRAME_ORIGINS`** path documented in the wiki — Bambuddy reachable on a stable URL (HTTP for HTTP-only HA, HTTPS via your own reverse proxy for HTTPS HA / Nabu Casa / custom-domain), iframe-embedded via the HA dashboard. HA Ingress / addon-based subpath embedding (which would require the runtime path detection above) is not supported by core. Documented explicitly in `docker.md` so users hit the right pattern first.

- **iframe embedding from trusted origins (e.g. Home Assistant Webpage panel) no longer blocked** ([#1191](https://github.com/maziggy/bambuddy/issues/1191), reported by @azurusnova) — Bambuddy ships strict anti-clickjacking headers (`X-Frame-Options: SAMEORIGIN` and CSP `frame-ancestors 'none'`) by default, which protects internet-exposed deployments from being embedded by hostile sites. But it also broke a documented integration path: Home Assistant's Webpage dashboard panel embeds Bambuddy via `<iframe>` on a different origin (HA on `:8123`, Bambuddy on `:8000`), and the SAMEORIGIN value is port-strict, so even same-LAN trusted setups got "refused to connect". A new `TRUSTED_FRAME_ORIGINS` env var takes a comma-separated list of `scheme://host[:port]` origins; when set, the middleware drops `X-Frame-Options` (modern browsers honor `frame-ancestors`, and the legacy `ALLOW-FROM <url>` syntax is deprecated and inconsistent across vendors) and the CSP `frame-ancestors` directive becomes `'self' <origin> <origin>...`. The default — empty env var — keeps the strict `'none'` behavior, so Docker / bare-metal users without HA see no behavioural change. Origin validation happens at startup: only `http://` and `https://` are accepted, paths/query/fragments/wildcards are rejected with a warning (one bad entry doesn't take the deployment down — it's just dropped from the allowlist). The `gcode-viewer` route's `frame-ancestors 'self'` (same-origin embed for the in-app gcode preview iframe) also includes the allowlist when configured, so HA users embedding Bambuddy can still open the gcode viewer modal. 16 new tests in `test_security_headers.py`: 12 unit tests for the env-var parser (empty / unset / single / multiple / whitespace / empty-segment / non-http scheme dropped / missing host dropped / path dropped / query+fragment dropped / wildcard dropped / trailing-slash kept) and 4 integration tests for the middleware (default-strict emits SAMEORIGIN + 'none', allowlist relaxes CSP and drops X-Frame-Options, /docs branch also honors the allowlist, other security headers like X-Content-Type-Options and Referrer-Policy are unaffected in both modes). Documented in the Docker env-var reference page on the wiki and in `.env.example`.

- **Virtual Printer queue mode auto-dispatched onto the wrong colour when multiple compatible printers were available** ([#1188](https://github.com/maziggy/bambuddy/issues/1188), reported by @EdwardChamberlain) — Sending a sliced 3MF to a queue-mode VP via Orca / Studio with auto-dispatch on caused Bambuddy to schedule the job onto a printer of the right model but the wrong loaded filament: a print sliced for matte white PLA would land on a printer with no white loaded, and the printer would start the job using whatever was the closest available match. Edward's diagnosis was exact (`virtual_printer/manager.py:325-326`): the manual /api/v1/print-queue/ POST flow extracts the 3MF's per-slot filament requirements at queue-add time and writes `required_filament_types`, `filament_overrides`, and `ams_mapping` on the resulting `PrintQueueItem`, so the scheduler's color-match enforcement (`print_scheduler.py:512` — keys on `filament_overrides[].force_color_match === true`) actually runs. The VP queue-write path (`_add_to_print_queue`) skipped all of that and built a bare `PrintQueueItem` with only `printer_id`, `target_model`, `archive_id`, `plate_id`, `position`, `status`, `manual_start`. Net effect: the scheduler reached the model-only-matching fallback and accepted the first available printer of the target model regardless of loaded colour, exactly as he described. **Fix:** the scheduler's existing `_get_filament_requirements` 3MF parser is extracted into a shared helper (`backend/app/services/filament_requirements.py:extract_filament_requirements`) so the VP path can reuse it at upload time. The VP's `_add_to_print_queue` now calls that helper after archiving and populates `required_filament_types` unconditionally (cheap; helps the scheduler reject obvious type mismatches even without `force_color_match`); and writes `filament_overrides` with `force_color_match: true` per consumed slot when a new per-VP setting `queue_force_color_match` is on. Default is **off** to preserve current behaviour for upgraders — a fresh-install user who wants the bug-free behaviour flips the toggle once on the VP card; an existing user gets exactly the model-only-matching they had before until they opt in. Auto-dispatch onto the wrong material happens loudly enough that anyone affected can find the toggle. **Why default-off** rather than default-on: existing automation that relies on "send to queue VP, get printed somewhere" without caring about colour shouldn't silently start blocking on colour matching after an upgrade. The toggle has clear UI copy (`virtualPrinter.queueForceColorMatch`) explaining the trade-off. **Defence in depth:** a malformed or unparseable 3MF (e.g. fake bytes from a misconfigured upload tool) leaves both fields None and the scheduler falls back to model-only matching, matching pre-fix behaviour for the unhappy path. The scheduler itself is unchanged — it already handled `force_color_match` correctly when the field was populated; the bug was purely the VP path not populating it. **Schema:** one nullable column `virtual_printers.queue_force_color_match BOOLEAN DEFAULT 0/FALSE` (Postgres-safe) added via the existing `_safe_execute` migration pattern. **API:** `VirtualPrinterCreate` and `VirtualPrinterUpdate` Pydantic schemas + `_vp_to_dict` response shape carry `queue_force_color_match`, the create + update routes wire it through to the model, and `VirtualPrinterInstance` constructor + `multiVirtualPrinterApi` TypeScript client mirror the field. **UI:** new toggle on `VirtualPrinterCard` rendered only when `mode === 'print_queue'` (parallels the existing `auto_dispatch` toggle's mode-gating), with `pendingAction` state for the in-flight indicator. **i18n:** new `virtualPrinter.queueForceColorMatch.{title,description}` keys in all 8 locales — English fully translated, German fully translated, the other 6 locales seeded with English copy pending native translation (matches the project's existing flow for newly-added user-facing features). 11 new tests: 8 in `test_filament_requirements.py` covering the extracted parser end-to-end (per-slot dicts, zero-use slots filtered, plate filtering, no-plate flat-walk fallback, unparseable / missing / config-less files, sorted output); 3 in `test_virtual_printer.py::TestVirtualPrinterInstance` covering the VP write path (setting-off → only `required_filament_types` populated; setting-on → `filament_overrides` populated with `force_color_match: true` per slot; unparseable 3MF → both fields None, no crash). Existing scheduler tests still pass against the refactored helper (verified end-to-end across the scheduler / virtual_printer / print_queue / filament test suites — 479 tests). Edward's "out of scope nice-to-have" suggestion of a "Requires Color Match" pill on queue cards is deferred to a follow-up so this PR stays scoped to his repro.

- **Slicing a library file via API key fails with "no Bambu Cloud session is stored" even when the key has cloud access** ([#1182](https://github.com/maziggy/bambuddy/issues/1182) follow-up, reported by @turulix) — Tim shipped the headless slicing pipeline #1182 was filed for, then hit a second wall: `GET /api/v1/cloud/settings` returned the cloud preset IDs correctly (the `/cloud/*` router-level gate from #1182 was doing its job), but `POST /api/v1/library/files/{id}/slice` with those IDs in the request body failed the slice job with `error_status: 400, error_detail: "Cloud preset selected for printer, but no Bambu Cloud session is stored. Sign in to Bambu Cloud and retry."` Cause: the `/cloud/*` fix routes the API key's owner User through `cloud_caller` (a router-level gate stashes the owner on `request.state.api_key_owner`, route-level deps pull it back out), but the slice route lives on `/library/*` — different router, no gate, so when the auth dep returned `None` for the API-keyed request the slice route passed `current_user_id=None` straight through to `_run_slicer_with_fallback` → `_resolve_cloud(db, user=None)` → `get_stored_token(db, None)`, which falls back to the auth-disabled global `Settings` table. That table is empty in auth-enabled deployments, so cloud preset resolution failed even though the key's owner User had a perfectly valid `cloud_token` on their User row. Fix is a new route-level dep `resolve_api_key_cloud_owner` in `cloud.py` that's permissive (returns the owner User if the key has `can_access_cloud=true`, otherwise None — never raises) so it can be safely added to non-`/cloud/*` routes without breaking the local-presets path: a request with an API key that lacks the cloud scope still slices fine against local presets, and only fails with the existing "no Bambu Cloud session" error if it actually selects a cloud preset. Wired into `POST /library/files/{id}/slice` (Tim's blocker) and `GET /slicer/presets` (the SliceModal preset dropdown source — same root cause, would have hit anyone using the UI through an API-keyed reverse proxy). Both routes now resolve the cloud-token owner via `current_user or api_key_cloud_owner` instead of `current_user.id if current_user else None`. The auth gate's None-return for API keys is unchanged — keeping that fix scoped to the routes that actually need cloud-token resolution prevents accidental scope creep into other routes that fence on `current_user is None`. 4 new integration tests in `test_api_key_cloud_access.py::TestSliceRouteCloudOwnerResolution` pin the dep contract: returns the owner for a key with `can_access_cloud=True` and a valid owner; returns None for an owned key without the cloud scope (so cloud presets still 400 cleanly, local presets still slice); returns None for legacy ownerless keys; no-op for JWT and anonymous callers.

- **Project cover photo thumbnail too small to recognise the print** ([#1155](https://github.com/maziggy/bambuddy/issues/1155) follow-up, reported by @smandon) — The 40×40 thumbnail @smandon's MakerWorld download workflow relied on for "is this the model I'm looking for?" wasn't readable at that size; he asked for either a larger thumbnail or a click-to-enlarge full preview. Enlarging the thumbnail itself would shift the card layout and cost the dense grid he chose to use for browsing many projects, so the fix keeps the 40×40 thumbnail and shows a portal-mounted 384×384 popover on hover. The popover renders the full image in `object-contain` so tall portrait MakerWorld photos aren't cropped to a square, has `pointer-events-none` so it can't intercept hover and create a flicker loop, and `z-[100]` so it stacks above every sibling card in the grid. **Why a portal:** ProjectCard carries `overflow-hidden` (for its rounded-corner clipping and the color accent bar), so an in-tree popover gets clipped by the card the moment it extends past the card's bounds — exactly the cut-off behaviour @smandon reported on the second iteration. Rendering via `createPortal(..., document.body)` escapes every ancestor clipping context, and `position: fixed` with measurements from `getBoundingClientRect()` keeps the popover pinned next to the thumbnail regardless of where the card sits in the grid. **Edge handling:** if the thumbnail is near the viewport's right edge the popover flips to the LEFT side of the thumbnail; vertical position is clamped so the popover never overflows the window top or bottom. The thumbnail's own `onClick` is `stopPropagation`'d so hovering the popover area never accidentally triggers the parent card's "open project" navigation. 2 new tests in `ProjectsPage.test.tsx` pin the contract: hovering mounts the popover at document.body level (not nested in the card — a future refactor that drops the portal would re-introduce the clipping bug, and the test catches that); leaving unmounts it; the popover img points at the same cover-image URL as the small thumbnail with `object-contain`; cards without a cover_image_filename never mount the portal-rendering component (so a hover doesn't flash an empty preview).

- **Spool edit form lost the Extra Colours value on reopen, Dual Color rendered identically to Gradient, and the Sparkle / checkerboard visuals were too subtle** ([#1154](https://github.com/maziggy/bambuddy/issues/1154) follow-up, reported by @maugsburger) — Four issues against the multi-colour swatch work that landed for #1154. **(1) Extra Colours input didn't hydrate on edit reopen:** `ColorSection`'s draft buffer was seeded once via `useState(formData.extra_colors)`, but `SpoolFormModal` opens *before* its own `useEffect` populates `formData` from the spool record — so by the time the saved value landed, the input's local state had already been initialised to `''` and never re-synced. The COLOR preview banner above the input rendered correctly (consumes formData directly), making it obvious the data WAS persisted; only the input was stuck blank, which the user then had to retype to save anything else. Fix: a ref-guarded `useEffect` resyncs `extraColorsDraft` when `formData.extra_colors` changes via an external update (e.g. modal opening with a spool); the ref is updated inside `commitExtraColors` so the user's own typing is round-tripped without the resync clobbering it. **(2) `Dual Color` and `Gradient` produced the same diagonal blend:** `buildColorLayer` in `filamentSwatchHelpers.ts` ran the same `linear-gradient(135deg, ...)` for both effect types, so a "Dual Color" spool was visually indistinguishable from a "Gradient" one. Real dual-colour spools have two distinct bars on the reel — that's the whole point of the variant. Fix: when `effect_type` is `dual-color` or `tri-color`, build the colour layer as `linear-gradient(to right, c1 0% X%, c2 X% Y%, ...)` with CSS double-position stops (so the colour change is a *hard line* rather than a blend region) and equal-width segments across the stops; `gradient` keeps the original 135° smooth blend. The existing `multicolor` conic-gradient path is untouched. **(3) Sparkle effect was almost invisible on card-sized swatches:** the original 4-dot pattern (each ~1px) read fine on the small inline swatch but disappeared on the 60-pixel-tall inventory card banners — exactly where the user actually identifies a spool. Bumped to 13 flecks in mixed sizes (1px / 1.5px / 2px) and varying opacity (0.65 → 1.0) to give a depth-of-field "metal flake" feeling, distinct from solid + multi-colour. **(4) Checkerboard cell density scaled with the swatch:** the previous helper put `repeating-conic-gradient(...)` in the `background-image` and the caller applied `background-size: cover`, so the same 4-cell pattern was either tiny squares on a small swatch or four huge squares on a card-sized banner. Made `buildFilamentBackground()` return `{ backgroundImage, backgroundSize }` with per-layer sizes — painted layers stay `cover`, the checkerboard gets a fixed 12px tile so the cell density stays consistent regardless of element size and clearly reads as a transparency indicator rather than a multi-colour stripe. Updated the three existing call sites (`InventoryPage` group banner + spool card, `ColorSection` preview) to spread the returned style object directly. **8 new frontend tests** cover the four fixes: hard-split contract for Dual/Tri Color (3 tests + 1 regression guard that Dual ≠ Gradient for the same stops); Sparkle prominence (≥ 10 distinct radial-gradient layers in the rendered background); checkerboard density (last `backgroundSize` layer is a fixed pixel value, not `cover`); 4 hydration tests pinning the input restore path (fills when formData arrives via parent update, resyncs when the spool changes mid-form, doesn't clobber live user typing, clears when the new spool has no extra_colors).

- **Pending review card and the resulting archive name disagreed; `.gcode.3mf` filename suffix wasn't fully stripped** ([#1152](https://github.com/maziggy/bambuddy/issues/1152) follow-up, reported by @smandon) — Two distinct holes in the original #1152 fix surfaced when @smandon retested on the daily build. **(1) Suffix stripping was incomplete:** Bambu Studio's "Send to printer" dialog typically writes files like `Plate_1.gcode.3mf` (a sliced gcode payload wrapped in a 3MF container), but the archive's display stem was computed via `Path(name).stem`, which only drops the *last* suffix and left the user staring at `Plate_1.gcode` in the archive UI. **(2) The review card and the archive disagreed on what the print was called:** the pending-uploads panel always rendered the raw FTP filename, while the eventual `PrintArchive.print_name` resolved from the 3MF's embedded title (or, with the toggle on `filename`, the filename stem). Net effect: the user saw `Plate_1.gcode` in the review card and `Some Creator's Title` in the archive grid for the same item, with no toggle that flipped both views in lockstep. Fix has three pieces: a new `resolve_display_stem()` helper in `archive.py` that strips `.gcode.3mf` / `.3mf` / `.gcode` (case-insensitive) so both the archive *and* the review-side normalisation produce the same canonical stem; a new `PendingUpload.metadata_print_name` column populated at FTP-receive time by peeking at the 3MF's embedded title (so `/pending-uploads/` list calls don't have to reopen every 3MF on every render); and a new `PendingUploadResponse.display_name` computed field that mirrors `archive_print`'s exact precedence — `filename` toggle: stripped stem; `metadata` toggle (default): cached title or stripped stem. Frontend's `PendingUploadsPanel` reads `upload.display_name` (with `upload.filename` as a defensive fallback for any pre-migration row), and the raw filename is exposed as a tooltip so users can still inspect what actually arrived over FTP. Migration is one idempotent `ALTER TABLE pending_uploads ADD COLUMN metadata_print_name VARCHAR(255)` (Postgres/SQLite-safe); existing pending rows have NULL there and gracefully fall back to filename-stem behaviour. 14 unit tests pin the stripping rules (`Plate_1.gcode.3mf` → `Plate_1`, mixed case, dots in the middle, edge `.3mf`-only / `.gcode`-only, full-path inputs); 6 integration tests pin the response contract (default toggle uses metadata title when present, falls back to stripped stem when absent, `filename` toggle overrides metadata, `filename` toggle still strips the double suffix, `GET /{id}` exposes the same field, whitespace-only metadata behaves like absent); 3 frontend tests pin the review card's render path (resolved name shown, fallback to filename when display_name is empty, raw filename available via tooltip).

- **SpoolBuddy SSH update fails with "permission denied for user spoolbuddy" after Bambuddy keypair rotation** (reported during user testing) — Bambuddy's data dir at `<DATA_DIR>/spoolbuddy/ssh/` can get recreated outside the daemon's control (volume remount, container recreate, fresh deploy), at which point `get_or_create_keypair()` generates a new ed25519 keypair. The SpoolBuddy daemon previously only fetched and deployed Bambuddy's public key at registration time (`/devices/register`), so any rotation after a successful registration left the device's `~/.ssh/authorized_keys` pointing at a defunct public half — every "Update" click from the Bambuddy UI then failed with `Connection closed by authenticating user spoolbuddy [preauth]` until the daemon was restarted manually. Worse, every prior successful registration appended a fresh entry to `authorized_keys` without ever pruning the old one, so a typical device accumulated 5+ stale Bambuddy-tagged keys (each one a permanent backdoor for whichever Bambuddy keypair held the matching private half at the time it was deployed). Two-pronged fix: **(1)** the heartbeat response (`HeartbeatResponse`, `routes/spoolbuddy.py:282`) now carries the current `ssh_public_key` alongside the existing `pending_command` / calibration fields, so the daemon's heartbeat picks up a key rotation within one cycle instead of needing a service restart; the same `try/except Exception: pass` pattern as the registration response keeps a missing/unreadable backend key from breaking telemetry. **(2)** `_deploy_ssh_key()` in `daemon/main.py` now syncs rather than appends — it strips every line tagged `bambuddy-spoolbuddy`, writes the current key once, and is a no-op when already in sync (so it doesn't churn the file every heartbeat). User-managed entries (any line not tagged `bambuddy-spoolbuddy`) are preserved untouched. 5 new unit tests in `spoolbuddy/tests/test_deploy_ssh_key.py` (creates-when-missing → mode-600 file with the current key; pile-up-of-stale-keys → only current key remains, no growth; preserves-unrelated-user-keys → user's own SSH access untouched; idempotent-when-in-sync → no mtime change so heartbeat doesn't churn the file; swallows-write-errors → readonly-fs PermissionError doesn't crash the heartbeat loop). 2 new backend integration tests in `test_spoolbuddy.py::TestDeviceEndpoints` — `test_heartbeat_returns_ssh_public_key` (response carries the key on every heartbeat) and `test_heartbeat_ssh_key_failure_does_not_break_heartbeat` (backend key-read failure leaves `ssh_public_key: None` but the heartbeat still 200s).

- **External-camera frames returned as black on go2rtc and other MJPEG sources** ([#1177](https://github.com/maziggy/bambuddy/issues/1177), reported by @nkm8) — `_capture_mjpeg_frame` returned the very first JPEG it found in the stream's bytes (`backend/app/services/external_camera.py:282`), but many MJPEG sources — go2rtc most notably, and several IP cameras — emit a "warm-up" frame on the byte that follows connection accept: usually the last keyframe held in the encoder, which is often black or stale until the encoder catches up to live content. Subsequent frames on the same connection are fine. The reporter saw it across snapshot UX, finish photos in notifications, and timelapse — every code path that opens a fresh capture connection (snapshot endpoint, `[PHOTO-BG]` finish photo, plate-detection CV, Obico ML inference, layer timelapse, Settings → Test). His own observation that go2rtc's `/api/frame.jpeg` (single-frame, internally already warmed) is never black while the first frame off `/api/stream.mjpeg` is, matched the hypothesis exactly. Support-bundle evidence was clean: every black notification frame in his log was 11095 bytes (a pure-black 1280×720 JPEG encodes to ~10–15 KB on standard libjpeg quality settings), while every captured-after-warm-up frame from the same source was 30–45 KB. Fix: read past the first frame and return the second; if the connection closes / times out / hits the 5 MB buffer cap before a second frame ever arrives, fall back to the first so callers still get *something* (degrading slow / single-frame streams to None would regress every code path that relied on pre-fix behaviour). The inner-loop now drains every complete frame already in the buffer before pulling the next chunk so high-FPS sources that pack multiple frames per chunk are handled correctly. The `snapshot` / `rtsp` / `usb` capture paths and the live-view streaming endpoint (`generate_mjpeg_stream`) are untouched. 7 new regression tests in `test_external_camera.py::TestCaptureMjpegFrameWarmupSkip` cover (a) two-frames-in-two-chunks → second returned, (b) two-frames-in-one-chunk → second returned, (c) frame split across chunk boundary → assembled correctly, (d) single-frame stream → first returned via fallback (no None regression), (e) timeout after first frame → first returned via fallback, (f) zero-frame stream → None, (g) non-200 status → None. Latency penalty: at most one frame interval (typically 50 ms – 1 s on a steady stream). **Follow-up: optional snapshot URL override** — @nkm8 retested on the daily build and saw the warm-up skip help most of the time but the black-frame symptom still surfaced intermittently on his go2rtc setup, with the same workflow break (notification thumbnails black, snapshot UX black). His own bisect already pointed at the cleanest fix: go2rtc exposes `/api/frame.jpeg` as a dedicated single-frame endpoint that *never* returns the encoder's warm-up keyframe, while `/api/stream.mjpeg` always does on a fresh connection. New optional `external_camera_snapshot_url` column on `printers` (idempotent `ALTER TABLE` migration via `_safe_execute`, plumbed through `PrinterBase` / `PrinterUpdate` / `PrinterResponse` / `from_orm_with_roi` / TypeScript `Printer` + `PrinterCreate`); when set, every single-frame capture path (`/api/v1/printers/{id}/camera/snapshot`, `[SNAPSHOT]` notification thumbnails, `[PHOTO-BG]` finish photo, layer timelapse on every captured layer, Obico ML snapshot, plate-detect / calibrate-plate CV) routes through `_capture_snapshot()` on the override URL via plain HTTP GET, bypassing the warm-up-frame dance entirely. The override is camera-type-agnostic — set it once on the printer config and it applies regardless of whether the live stream is mjpeg / rtsp / usb. Live-view (the `/camera/stream` and `/camera` endpoints powering the in-app viewer) deliberately stays on the configured stream URL — the override only changes single-frame captures, since a 1 fps poll-the-snapshot-endpoint live view would be a regression for everyone who doesn't have this problem. Settings UI (Settings → General → External Cameras) renders a new "Snapshot URL (optional)" input with its own Test button below the live-stream URL row; the input is hidden when `camera_type === 'snapshot'` since the live URL is already a single-frame endpoint and the override would be redundant. SSRF guard on the override is the existing `_sanitize_camera_url("http", "https")` allowlist — link-local / metadata / blocked hosts return None instead of being fetched. Empty-string override is treated as unset (defence in depth — a stale config row that somehow has `""` rather than `NULL` still routes through the live stream rather than firing GET against an empty URL). 5 new backend tests in `test_external_camera.py::TestSnapshotUrlOverride` (override routes to snapshot path; no override → camera-type handler; empty string → camera-type handler; SSRF guard on metadata-target override returns None; override is camera-type-agnostic across rtsp/usb). 3 new frontend tests in `SettingsPage.test.tsx` (input renders for mjpeg/rtsp/usb camera types; hidden for snapshot type; debounced PATCH carries `external_camera_snapshot_url` when the user types). i18n: `settings.cameraSnapshotUrl{,Placeholder,Help}` in en + de fully translated, the other 6 locales (fr/it/ja/pt-BR/zh-CN/zh-TW) seeded with English copies pending native translation. Documented under `bambuddy-wiki/docs/features/camera.md` with the go2rtc example URL as a tip block.

- **MakerWorld sidebar entry visible to every user regardless of group permissions** ([#1175](https://github.com/maziggy/bambuddy/issues/1175)) — Backend already enforced `makerworld:view` on every `/makerworld/*` route (`backend/app/api/routes/makerworld.py:145, 157, 242, 406`), the permission was correctly granted to the admin and standard-user role defaults (`permissions.py:298, 364, 454`), and the frontend `Permission` type union already included `'makerworld:view' | 'makerworld:import'` (`client.ts:2498`) — but the sidebar's hand-maintained `navPermissions` map in `Layout.tsx:278` had no entry for `makerworld`, so `isHidden('makerworld')` always returned false and the entry rendered for every authenticated user. Users without the permission saw the entry, clicked, and the page rendered while every API call inside it 403'd. Two-line fix: (1) `Layout.tsx:278` — add `makerworld: 'makerworld:view'` to the map, matching every other sidebar entry's gating shape; (2) `App.tsx:200` — wrap the route in `<PermissionRoute permission="makerworld:view">` for defence in depth, so a user who knows the URL can no longer reach the page directly (matches the existing pattern on `settings`, `groups/new`, `groups/:id/edit` two lines below). 2 new Layout tests pin the contract: with auth enabled and a user lacking `makerworld:view`, the sidebar `<a href="/makerworld">` link is absent (other links like `/files` still render); with the permission granted, the link renders.

- **Printer Info modal: serial-number and IP-address copy buttons silently did nothing on plain-HTTP LAN deployments** ([#1174](https://github.com/maziggy/bambuddy/issues/1174), reported by @BurntOutHylian) — `PrinterInfoModal`'s `CopyButton` only tried `navigator.clipboard.writeText()`, which is gated by the secure-context requirement (HTTPS or localhost). On the typical Bambuddy deployment shape — bare-IP HTTP on the LAN — `navigator.clipboard` is undefined; the existing `try/catch` swallowed the resulting `TypeError`, the icon never flipped to the tick, and nothing landed on the user's clipboard. Fixed by adding the same off-screen-textarea + `document.execCommand('copy')` fallback that `CameraTokensPage`'s plaintext-token modal already uses for plain-HTTP LAN deployments: gate on `navigator.clipboard && window.isSecureContext`, fall back to the legacy path otherwise, and surface the success-tick only when the copy actually landed (return early without flipping `copied` if `execCommand('copy')` returns false). The `try/finally` around the textarea guarantees DOM cleanup even when the browser throws on a restricted context. 3 new component tests in `PrinterInfoModal.test.tsx` cover (a) secure-context happy path uses `navigator.clipboard.writeText`, (b) plain-HTTP fallback path actually invokes `execCommand('copy')` and leaves no leaked textarea in the DOM, (c) `finally` cleanup removes the textarea even when `execCommand` throws synthetically. Thanks to @BurntOutHylian for the precise file/line pointer in the report.

- **Queue auto-dispatched the next print onto a fouled bed after an aborted or cancelled print** ([#1171](https://github.com/maziggy/bambuddy/issues/1171), reported by @tom5677) — When a print ended with status `aborted` (printer self-abort, or a user stopping the print on the printer's own touchscreen) or `cancelled` (user stopping the print via the Bambuddy queue UI), the plate-clear gate added in [#961](https://github.com/maziggy/bambuddy/issues/961) was *not* raised — only `completed` and `failed` triggered it (`backend/app/main.py:2660`). Result: the queue scheduler dispatched the next pending item ~2 seconds after the abort, with the previous print's material still on the bed. The reporter saw two prints (P1P + P1S) auto-start onto fouled beds within seconds of each other after touchscreen-aborts, and explicitly flagged the risk of damage to the printer; a third printer (his second P1S) behaved correctly because its previous print had ended `completed`. The original code's comment ("user-cancelled prints don't require a plate-clear ack — nothing printed on the bed") only holds if you cancel right at layer 1; cancelling a 12-hour print at hour 11 leaves a fouled bed too. Fix: the gate is now raised for every terminal status — `completed`, `failed`, `aborted`, `cancelled` — matching the safety contract that the user must acknowledge the bed is clear before any next queued print starts. The gate is user-clearable on the Printers page, so worst case for a layer-1 cancel the user clicks "Clear Plate" once. Touchscreen-aborts are particularly important to gate because Bambuddy's "user stopped via UI" override (`_user_stopped_printers` → `aborted` mapped to `cancelled`) only fires when the user stops via the Bambuddy queue; a touchscreen-stop reports `aborted` straight through. Regression coverage in `test_print_lifecycle.py::TestPlateClearGate`: parametrised across all four terminal statuses (asserts `set_awaiting_plate_clear(printer_id, True)` is called for each), plus a defence-in-depth test that an unrecognised future status string never silently raises the gate.

- **Printer card always shows the first plate's thumbnail when printing a multi-plate 3MF** ([#1166](https://github.com/maziggy/bambuddy/issues/1166), reported by @smandon) — On printers running firmware that drops the plate path from `print.gcode_file` (the reporter's case: P1S 01.10.00.00, but the same shape appears on other firmware revisions), the printer reports `gcode_file: MyModel.3mf` instead of `gcode_file: /Metadata/plate_4.gcode`. The `/printers/{id}/cover` route's regex (`plate_(\d+)\.gcode`) found nothing in the bare `.3mf` filename, defaulted to plate 1, and the printer card showed `Metadata/plate_1.png` from the 3MF — even though the user dispatched plate 4. Same problem hit `current_plate_id` on the status response (printer card detail row showed plate 1). Two-pronged fix on a precedence ladder: **(1) Bambuddy now records the plate it dispatched** — `start_print()` writes `(dispatched_plate_id, dispatched_subtask)` onto `PrinterState` at publish time, and a new `resolve_plate_id(state)` helper prefers that record over the gcode_file regex when `dispatched_subtask == state.subtask_name` (the subtask check rejects stale entries from a prior Bambuddy-dispatched print bleeding into a Studio-direct dispatch). **(2) After the 3MF lands on disk, the cover route scans the zip for a unique `Metadata/plate_*.gcode` entry**: per-plate archives sliced separately in Bambu Studio bundle thumbnails for *every* plate but only the *active* plate's gcode, so a single match unambiguously identifies the plate even when no Bambuddy dispatch exists (Studio-direct flow). Final fallback is plate 1, unchanged. The cover-byte cache key was also simplified — `plate_num` was removed from the key now that resolution is late-bound; `clear_cover_cache()` already runs on every print start, so different plates of the same project always re-fetch a fresh thumbnail. Coverage: 5 unit tests in `test_printer_manager.py::TestResolvePlateId` (dispatch precedence, stale-subtask guard, gcode regex fallback, default-1 path, missing-subtask guard), 4 unit tests in `test_bambu_mqtt.py::TestStartPrintRecordsDispatchedPlate` (dispatch record set/cleared/overwritten/skipped on disconnect), 2 integration tests in `test_printers_api.py` (dispatch wins over plate-1 default; 3MF-scan fallback for per-plate archive without dispatch). Studio-direct multi-plate prints (no dispatch record AND multiple plate gcodes in the 3MF) still default to plate 1 — matches the firmware's own ambiguity, not regressed by this change.

- **AMS slot configuration intermittently fails to reach the printer after several configs in a row** ([#1164](https://github.com/maziggy/bambuddy/issues/1164), reported by @RosdasHH) — Configuring AMS slots a handful of times (the reporter saw it almost every 6th change) would silently stop reaching the printer; ~1 minute later the filament colours on the printer would briefly jump between slots, then settle. Root cause was the zombie-session watchdog at `bambu_mqtt.py:861` introduced for [#887](https://github.com/maziggy/bambuddy/issues/887). When an `ams_filament_setting` response took >10 s (normal under load — concurrent K-profile fetches, busy printer, network jitter) the watchdog incremented an `_ams_cmd_unanswered` counter and zeroed `_last_ams_cmd_time` so it wouldn't re-trigger on the next status push. The bug: the response handler that reset the counter was guarded by `and self._last_ams_cmd_time > 0` — so when the late response *did* arrive (after the watchdog had already zeroed the timer), the counter stayed armed at 1. The next slow response on any `ams_filament_setting` command — possibly minutes or hours later, on an entirely unrelated config attempt — would take the counter to 2 and trigger `force_reconnect_stale_session()`. The user-visible symptoms match exactly: configs stop landing (because MQTT reconnects mid-publish, dropping the in-flight command and surfacing as `Cannot set AMS filament setting: not connected` if the user retries during the ~1 min reconnect window), then the queued state finally lands when the reconnect completes (the "filament colours jumping around" the reporter described). Fix is to drop the `_last_ams_cmd_time > 0` guard: any `ams_filament_setting` response — late or not — proves the channel is alive, so the counter must reset. Watchdog still trips on a real zombie session (no responses at all for two consecutive >10 s windows). Regression test in `test_bambu_mqtt.py::TestZombieSessionDetection::test_late_response_after_watchdog_clears_counter_issue_1164` simulates the exact sequence (watchdog fires → late response arrives → second slow response on a fresh command) and asserts the counter resets to 0 on the late response and the second command doesn't tip the threshold to 2. Other 10 zombie-detection tests still pass unchanged. **Follow-up: cumulative session wedge after ~16-20 commands** — the watchdog fix above heals real zombie sessions, but @RosdasHH continued to see the wedge fire on healthy sessions after enough cumulative commands (configs + spool assignments share the same threshold: "8 + 3", "12 + 1", "16 + 0" all tripped it). His QoS=1 vs QoS=0 vs QoS=2 bisect was the breakthrough — the wedge only happens at QoS=1. paho-mqtt's default `max_inflight_messages` is 20, and Bambu's broker has racy PUBACK matching that leaves some inflight slots unreleased per session, so after ~16-20 cumulative commands the queue silently fills and `publish()` returns success while packets sit in paho's internal queue (force_reconnect heals it because the inflight queue is per-session — the printer had already processed every command, it just couldn't receive any new ones until the session reset). Lifted the ceiling to 1000 via `client.max_inflight_messages_set(1000)` immediately after `mqtt.Client()` construction (`bambu_mqtt.py:3074-3079`). Keeps QoS=1 untouched (the cross-model reliability we deliberately chose for AMS configuration — A1, P1S, X1C, H2D, P2S, X2D all need it) and removes the ceiling as the bottleneck without changing wire-protocol behaviour. The watchdog reconnect from the original fix above stays as defence-in-depth for sessions that go truly zombie. Diagnosis credit: @RosdasHH's careful bisect.

## [0.2.4b1] - 2026-04-29

### Added
- **Enhanced filament colour handling: multi-colour gradients, transparency, visual effects** ([#1154](https://github.com/maziggy/bambuddy/issues/1154)) — A solid hex swatch is the wrong abstraction for a tri-colour, gradient, or sparkle filament — the colour you saw on the spool inventory page was just whatever Bambu's firmware reported as the dominant tone, and there was no way to record what the spool actually looked like. The Spool form's "Colour" section now accepts a paste of up to 8 comma-separated hex stops (`EC984C,#6CD4BC,A66EB9,D87694` — exact format from 3dfilamentprofiles.com) and renders them as a CSS gradient on every swatch site (inventory grid, table, group banner, card, ColorSection preview, color-catalog admin). A new **Effect** dropdown — covering surface effects (Sparkle / Wood / Marble / Glow / Matte), sheen variants (Silk / Galaxy / Rainbow / Metal / Translucent), and structural variants (Gradient / Dual Color / Tri Color / Multicolor) — layers a CSS overlay on top of the colour layer (or, for Multicolor, switches the colour layer to a conic-gradient even when no spool subtype is set, so the catalog editor can flag a multicolor variant directly without needing a paired Spool row). Independent of `subtype` — so the user can override the visual hint without touching Bambu's categorical filament label or the MQTT auto-detection chain. **Transparency is now actually visible**: the existing `rgba` column has always stored an alpha byte but every render site flattened it with `substring(0, 6)`; the new shared `<FilamentSwatch>` component renders against a checkerboard layer beneath the colour layer so any alpha < 0xFF shows through (matches the convention used by image editors and 3dfilamentprofiles.com). **Multicolor subtype** swaps the linear gradient for a `conic-gradient` so the swatch reads as a colour wheel pie instead of a stripe — visually distinguishes a true multi-colour spool from a 2-stop gradient. **Colour-catalog parity**: the same fields land on `ColorCatalogEntry` (Settings → Color Catalog) so a user can save a multi-colour combo once and pick it from the catalog palette across spools — added inline to both the Add form and the inline-edit row, threaded through the JSON export/import path so catalog backups round-trip the new fields. Catalog `hex_color` regex extended to optionally accept `#RRGGBBAA` for transparency-aware catalog entries (backward-compatible — existing 6-char rows still validate). **Schema validation** (`backend/app/schemas/spool.py::normalize_extra_colors` + `normalize_effect_type` — public so `ColorEntryCreate` / `ColorEntryUpdate` can reuse them): comma-separated hex with 6-or-8-char tokens, lowercase canonical form, `#` prefix stripped, max-8-stop cap, empty tokens dropped (so a degenerate paste like `,,FF0000,` survives), invalid tokens rejected at the Pydantic layer with a precise field error. Effect type validated against the fixed set `{sparkle, wood, marble, glow, matte, silk, galaxy, rainbow, metal, translucent, gradient, dual-color, tri-color, multicolor}` — paste-friendly normaliser tolerates `Dual Color` / `dual_color` / `dual-color` and canonicalises to `dual-color`. Both validators live next to `SpoolBase` and are reused by `ColorEntryCreate` / `ColorEntryUpdate` so spool-side and catalog-side rejection rules can never drift. **Frontend swatch component** is one shared `<FilamentSwatch>` (and `buildFilamentBackground()` helper for callers that want just the CSS background-image string for a banner) — used by InventoryPage table, group banner, SpoolCard, ColorSection preview, and ColorCatalogSettings — so there's exactly one place that decides how a filament looks. Colour layer is built as a list of CSS images (no `background:` shorthand) so jsdom and every browser parse it consistently; checkerboard layer is the one that makes alpha visible. Migrations are 4 idempotent `ALTER TABLE ... ADD COLUMN` (Postgres-safe, no `DEFAULT 0` traps) plus a Postgres-only widen of `color_catalog.hex_color` to `VARCHAR(9)`. **i18n**: 12 new keys under `inventory.*` across all 8 locales (en/de/zh-CN/zh-TW fully translated; fr/it/ja/pt-BR seeded with English copies pending native translation, matching the project's flow for newly-added user-facing features). 42 new backend tests (35 unit + 7 integration) covering the normalizer (paste-from-3dfilamentprofiles canonicalisation, whitespace tolerance, mixed 6/8-char, empty-token drop, max-stop cap, invalid-hex rejection, wrong-length rejection, `Dual Color` / `dual_color` → `dual-color` canonicalisation), effect-type validator across all 14 allowed values, end-to-end POST/PUT/PATCH round-trip on both spool + catalog routes, 8-char `hex_color` acceptance, dedupe-on-update, and field clearing via empty string vs explicit null. 20 new frontend tests covering FilamentSwatch (14 — solid render, multi-stop linear gradient, conic for Multicolor *subtype* AND for `multicolor` *effect_type* via the catalog path, surface-effect overlays for sparkle and silk, categorical-only-no-overlay for gradient/dual-color, unknown-effect-ignored, checkerboard rendering for alpha, invalid-hex skip in stops, title fallback), `buildFilamentBackground` helper, ColorCatalogSettings (3 — Add form sends extra_colors + effect_type, full 14-value dropdown, inline edit hydrates from existing entry), and InventoryPage spool grouping (3 — different extra_colors don't collapse, different effect_type don't collapse, identical multi-colour spools still group). Out of scope for V1: gradient stop *positions* (e.g. 25%/75%), MQTT-side auto-import of multi-colour from Bambu (firmware doesn't expose this), per-effect tunable parameters — the current shape closes the user's actual paste-from-3dfilamentprofiles workflow without taking on a structured-stop-position editor.
- **Project URL + cover photo** ([#1155](https://github.com/maziggy/bambuddy/issues/1155)) — Two new fields on every project: a free-text **URL** (rendered as a 24×24 bordered green button beside the project name on every card; opens in a new tab and click is `e.stopPropagation()`-guarded so it doesn't enter the project) and a **cover photo** (replaces the status-icon box on the card with a square thumbnail). The URL field is plumbed through `ProjectCreate`/`ProjectUpdate`/`ProjectResponse`/`ProjectListResponse`, including from-template + create-template flows so the URL inherits between a project and its template; cover photo is *not* inherited because the file would be shared on disk between the source and copy. **Schema validator** rejects anything other than `http://` or `https://` prefixes — `<a href>` rendering would otherwise execute `javascript:` / `data:` / `file:` URLs even with React's default escaping. **Cover image storage**: `Project.cover_image_filename` references a file inside the existing `archives/projects/{id}/attachments/` directory, but it's tracked as a separate column from the `attachments` JSON list so swap/delete operations on the cover don't perturb the user's other attachments. Three new routes (`POST /projects/{id}/cover-image`, `GET /projects/{id}/cover-image`, `DELETE /projects/{id}/cover-image`) — accepts only `.jpg`/`.jpeg`/`.png`/`.gif`/`.webp` (no SVG: SVG can carry script payloads), replaces in place (the prior file is removed from disk before the new one lands so repeat uploads can't accumulate orphans), and self-heals when a DB reference points at a vanished disk file by clearing the column and 404'ing rather than repeatedly touching the filesystem. **GET auth gate**: the cover-image GET route is gated by `RequireCameraStreamTokenIfAuthEnabled` (accepts the same `?token=…` stream credential the archive thumbnail route uses) rather than the bearer-token gate — `<img src>` requests can't carry an `Authorization` header, and the bearer gate would silently 401 every cover image when auth is enabled. The frontend client wraps the URL with `withStreamToken(...)` so the modal preview AND the card thumbnail load in both auth-on and auth-off configurations. **PATCH update** uses `model_fields_set` for the URL field so users can clear it by sending `{"url": null}`. Permissions: `PROJECTS_UPDATE` for upload/delete/PATCH, `PROJECTS_READ` for the GET (via the stream-token gate). **Migration**: 2 idempotent `ALTER TABLE projects ADD COLUMN ...` statements. Localised across all 8 UI languages (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW) — English fully translated, the seven other locales seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features. 7 backend integration tests covering URL accept/reject (https/javascript/data), URL clear, cover image upload→serve→delete round-trip with content-type assertion, non-image rejection, and a regression guard verifying the GET route is wired to the stream-token gate (not the bearer gate). 4 frontend ProjectsPage tests covering the link icon render condition, click-propagation guard, no-link-when-unset, and cover-image thumbnail render; 3 frontend client tests pinning that `getProjectCoverImageUrl` appends the stream token, returns the bare URL when no token is set, and URL-encodes tokens with query-string-unsafe characters. Cover image upload is only available on the edit modal (an existing project), since the upload needs a project_id; new projects can add it after first save.
- **"Not Printed" / "Printed" collections on the Archives page** ([#1153](https://github.com/maziggy/bambuddy/issues/1153)) — Virtual-printer uploads land in the archives view with `status='archived'` (uploaded but never sent to a printer), but the existing `Collection` sidebar only had `All / Recent / This Week / This Month / Favorites / Failed / Duplicates` so there was no way to surface "what's still queued in my library that I haven't printed yet" vs "what already went to a printer." Two new collections fill that gap: **Not Printed** filters to `status === 'archived'` (the VP upload state); **Printed** filters to any final-status archive — `completed`, `failed`, `aborted`, `cancelled`, `stopped` — so a user can see every archive that had a print attempt regardless of outcome (the existing "Failed" collection covers just the failure subset). Frontend-only — the data has always been there, just no UI handle for it. 2 new tests in `ArchivesPage.test.tsx::Not Printed / Printed collections` pin the filter behaviour against a fixture covering all 4 status states (archived / completed / failed / cancelled).
- **Virtual-printer archive name source toggle** ([#1152](https://github.com/maziggy/bambuddy/issues/1152)) — Slicer-uploaded archives picked up their display name from the 3MF's embedded `print_name` metadata, which is whatever the original creator set; users who renamed a job in BambuStudio's "Send to printer" dialog never saw that name surface in Bambuddy because the FTP-uploaded filename was only ever used as a fallback when the metadata was empty. Settings → Virtual Printer now exposes an **Archive name source** toggle (Metadata / Filename, default Metadata, preserves existing behaviour) at the top of the page that flips precedence in `ArchiveService.archive_print` for every VP-sourced archive — `_archive_file`, `_add_to_print_queue`, `POST /pending-uploads/archive-all`, and `POST /pending-uploads/{id}/archive` all read the new `virtual_printer_archive_name_source` setting and forward `prefer_filename_for_name` accordingly. Backend validates the value to `metadata`/`filename` only. Strict locales (en/de/zh-CN/zh-TW) get full translations; 4 unit tests parametrised over `filename` / `metadata` / unset / empty-string pin the precedence rule end-to-end through `_archive_file`. Existing post-archive `PATCH /archives/{id}` rename path is unchanged.
- **Multi-color slicing in the Slice modal, with per-plate filament discovery for unsliced project files** — Initial slice support assumed a single filament profile per slice; multi-color 3MFs were silently truncated to the first slot, producing wrong colours on every non-trivial print. The Slice modal now (1) opens a plate-picker step first when the source is a multi-plate 3MF, (2) renders one filament dropdown per AMS slot the picked plate actually uses, with each dropdown auto-populated against the user's local + standard presets by `(filament_type, filament_colour)` match, and (3) submits the user's picks as an ordered `filament_presets: PresetRef[]` array which is forwarded as repeated `filamentProfile` multipart parts to the slicer sidecar (the CLI joins them with `;` for `--load-filaments`). **Per-plate filament list source-of-truth chain**: for a sliced archive the modal reads `Metadata/slice_info.config` directly (existing path); for an unsliced project file (where `slice_info.config` is empty until Bambu Studio actually slices), the new `slice_preview` service runs a fast preview-slice via the sidecar's `slice_without_profiles` (the project's embedded settings drive the slice; we throw away the gcode and only parse the resulting slice_info), and the result is cached by `(kind, source_id, plate_id, content_hash)` with LRU eviction at 256 entries — repeat opens of the same plate are instant. If the sidecar isn't reachable the modal falls back to a heuristic that reads `Metadata/project_settings.config` for the AMS slot config and intersects it with the plate's painted-face data (`paint_color` quadtree leaves on per-object .model files, scanned with a 5% noise threshold to drop single-leaf edit accidents). **SliceModal-only tier priority is now `local → cloud → standard`** (was `cloud → local → standard`): imported profiles win because they carry parsed type/colour metadata in the response, while cloud entries don't (the per-preset detail endpoint rate-limits at ~10/sec per token and 50+ parallel fetches returned 429 on every request). The unified-listing endpoint's dedup pass now backfills metadata cross-tier — if a cloud entry wins dedup over a same-named local entry, the cloud entry inherits the local's `filament_type` / `filament_colour` so the Slice modal's metadata-aware pre-pick keeps working for users who have presets both cloud-synced and locally imported. Other consumers of `/slicer/presets` (Profiles page, etc.) retain the existing cloud-first dedup. **Sidecar** (orca-slicer-api fork, `bambuddy/profile-resolver` branch): `/slice` now accepts up to 16 repeated `filamentProfile` parts (was hard-capped at 1), the slicing service materializes each as `filament_N.json` and joins paths into a single `--load-filaments "a.json;b.json;c.json"` invocation; `/profiles/bundled` listing was extended with `filament_type` and `filament_colour` per leaf so the bundled tier carries metadata into the modal. **Sliced-archive card now reflects the actually-used filament list, not the project-wide AMS config**: `slice_and_persist_as_archive` previously copied `filament_type` and `filament_color` from the unsliced source archive verbatim, which inherited every project-wide AMS slot (16+ swatches on the card for a 2-color print). The new archive now reads those fields from the sliced output's `slice_info.config` via `ThreeMFParser` (which already gates on `used_g > 0`), falling back to the source archive's values only if parsing failed. **Backwards compatibility**: `SliceRequest` schema accepts three shapes — legacy `filament_preset_id: int`, source-aware singular `filament_preset: PresetRef`, multi-color array `filament_presets: list[PresetRef]` — the validator promotes any of them into a populated `filament_presets` list before the route handler runs, and stale browser tabs from before this change keep working unchanged. **Permissions**: no new endpoint paths added; the preview-slice runs inside `/filament-requirements` (gated on `LIBRARY_READ` / `ARCHIVES_READ`) and the multi-filament dispatch runs inside `POST /slice` (gated on `LIBRARY_UPLOAD`) — no auth surface widened. **Tests**: 6 schema tests for `SliceRequest` covering the multi-filament list shape and legacy-vs-new precedence; 9 unit tests for `slice_preview` covering happy path, content-hash invalidation, sidecar-failure no-cache-poison, concurrent-call thundering-herd guard via per-key `asyncio.Lock`, and LRU eviction-with-lock-cleanup; 15 unit tests for `extract_project_filaments_from_3mf` (5 cases) and `extract_plate_extruder_set_from_3mf` (10 cases including the 60/40 painted-threshold pin); a multi-filament wire-format test on `slice_with_profiles` pinning that N filament profiles produce N repeated multipart parts in submission order; 22 frontend SliceModal tests covering the plate picker step, multi-color rendering, metadata-aware pre-pick, manual slot override, archive-vs-library routing, and the new tier order. Localised across all 8 UI languages (English + German fully translated, the six others seeded with English copies pending native translation per the project's existing flow).

- **Slicer presets now span Cloud, imported, and slicer-bundled tiers, end-to-end** — Initial slicer integration only saw DB-backed local imports, so a user without imported profiles got an empty Slice modal even when their Bambu Cloud account or the slicer sidecar carried perfectly usable presets. The Slice modal now pulls from three tiers in priority order — **cloud** (the user's own Bambu Cloud presets), **local** (DB-backed imports), **standard** (slicer-bundled stock profiles) — with name-based dedup so a preset that exists in multiple tiers only renders in the highest-priority one (cloud > local > standard) and within-tier order is preserved exactly. **Listing** (`GET /api/v1/slicer/presets`): cloud branch is per-user with a 5-minute cache keyed on `(user_id, sha256(token)[:16])` so a logout/login or token rotation auto-invalidates without callback wiring from the cloud-auth routes. Bundled branch is global with a 1-hour cache (sidecar's read-only filesystem only changes across image rebuilds). `cloud_status` (`ok` / `not_authenticated` / `expired` / `unreachable`) drives a precise modal banner instead of an unexplained empty list. **Slicing** (`POST /library/files/{id}/slice`, `POST /archives/{id}/slice`): request body now accepts source-aware `{source, id}` triplets per slot (cloud / local / standard) alongside the legacy `*_preset_id` fields for full backwards-compatibility — the schema validator normalises bare integer ids into `PresetRef(source='local', id=str(int))` so the dispatcher only deals with one shape. New `preset_resolver` service fetches the preset content per source: cloud via `BambuCloudService.get_setting_detail` (unwraps the `setting` envelope, falls back to top-level on minor shape variants), local from the DB (existing path), standard via a minimal `{inherits: <name>, from: "system"}` stub that the sidecar's `bambuddy/profile-resolver` branch flattens against `BUNDLED_PROFILES_PATH/<category>/<name>.json` — no preset-content round-trip needed for the standard tier. **Permissions**: the listing route gate matches the slice action itself (`LIBRARY_UPLOAD`) so any user who can slice can populate the dropdowns; the cloud branch has an independent `CLOUD_AUTH` check inside the fetch helper — a user holding `LIBRARY_UPLOAD` but not `CLOUD_AUTH` doesn't see the cloud tier (and can't slice with a cloud preset, returns 403) even if a leftover `User.cloud_token` survived a permission revocation. **SliceModal** (frontend): grouped `<optgroup>` per tier with localised section headers, default-selection follows the cloud > local > standard priority on first load, cloud-status banner with three variants (sign-in / expired / unreachable) only when the status isn't `ok`. **Sidecar** (orca-slicer-api fork, `bambuddy/profile-resolver` branch): new `GET /profiles/bundled` walks `BUNDLED_PROFILES_PATH/{machine,process,filament}` and returns instantiable presets only (`instantiation: "true"`), filtering out abstract bases like `fdm_filament_pla` so the dropdowns only offer things a user can actually pick. **Tests**: 17 unit tests for the listing endpoint helpers (dedup priority + per-slot scoping + order preservation, all four `cloud_status` states, `CLOUD_AUTH` defence-in-depth with token lookup short-circuit, per-user cache isolation, token-change cache invalidation, sidecar-unreachable fallback), 11 unit tests for the source-aware resolver (standard inherits-stub shape, local DB lookup with `preset_type` validation, cloud envelope unwrapping with both standard and top-level shapes, cloud auth-error → 401, cloud `CLOUD_AUTH` defence, slot dispatch routing), 6 schema tests for `SliceRequest` covering legacy bare-int normalisation and new source-aware refs and explicit-ref-wins-over-legacy precedence, 12 frontend tests for SliceModal covering tier-priority auto-selection, `<optgroup>` grouping, fallback when higher tiers are empty, source-aware payload on submit, manual override across tiers, archive-vs-library routing, error display, and all three banner variants. All 3391 backend + 1531 frontend tests pass.

- **Server-side slicing via OrcaSlicer / Bambu Studio sidecar** — Bambuddy can now slice models without a desktop slicer installed. New optional `slicer-api/` Compose stack runs HTTP wrappers around the OrcaSlicer and/or Bambu Studio CLI; Bambuddy's File Manager and Archives pages get a **Slice** button that picks a printer / process / filament preset and dispatches a background slice job whose result lands as a new `.gcode.3mf` in the same library folder (or as a new archive when the source was an archive). Settings → Workflow gets a new **Slicer** card: pick the preferred slicer, toggle "Use Slicer API" on, and paste the sidecar URL — Slice buttons across File Manager, Archives, and MakerWorld then route through the API instead of the OS slicer URI scheme. Status updates come from a global `SliceJobTrackerProvider` that polls `/api/v1/slice-jobs/{id}` and surfaces a single toast per job (queued → running → completed / failed) plus auto-refreshes the file or archive list on success — slicing one file no longer pins the modal. Server side, a fresh in-memory dispatcher (`backend/app/services/slice_dispatch.py`) runs jobs as `asyncio.create_task`s with a 30-minute retention sweep, and the routes (`POST /library/files/{id}/slice`, `POST /archives/{id}/slice`) return 202 immediately with `{job_id, status, status_url}` instead of holding the request open through a multi-minute slice. The CLI bridge (`backend/app/services/slicer_api.py`) distinguishes 4xx (`SlicerInputError`), 5xx (`SlicerApiServerError`), and connection failures (`SlicerApiUnavailableError`) so 3MF inputs can transparently retry with embedded settings when the sidecar's `--load-settings` path segfaults on the input — empirically required for OrcaSlicer 2.3.x + H2D and signalled to the UI via `used_embedded_settings: true`. Sliced output is forced to `.gcode.3mf` so File Manager picks up the embedded thumbnail, the `print_name` is dropped from saved metadata so the displayed filename matches what the user picked, and `file_type="gcode"` paints the badge blue. The polling endpoint `GET /api/v1/slice-jobs/{id}` is gated on `LIBRARY_READ` since job IDs are sequential and the body leaks source filenames + resulting library/archive IDs. The sidecar itself builds from a fork of [AFKFelix/orca-slicer-api](https://github.com/AFKFelix/orca-slicer-api) (`maziggy/orca-slicer-api@bambuddy/profile-resolver`) which adds the `inherits:` chain resolver, `from: "User"` → `"system"` rewrite, `# ` clone-prefix strip, and sentinel-value strip empirically required to slice real OrcaSlicer GUI exports without segfaulting the CLI; the Compose file uses Docker's git-build-context so users don't clone it manually. Default ports are 3003 (orca) and 3001 (bambu-studio) — 3000/3002 are skipped because Bambuddy's virtual-printer feature owns them. 10 backend integration tests cover sync validation (404/400), happy-path enqueue, preset-error → failed job, sidecar unreachable, the 3MF embedded-settings fallback, STL no-fallback, and the strip-before-forward path; 5 new frontend tests for the SliceModal cover preset gating, library + archive enqueue paths, error display, and preset-load failure. New i18n keys under `slicer.*` and `settings.slicer.*` across all 8 locales (English fully translated; the seven other locales seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features). Slicer integration is opt-in: if "Use Slicer API" stays off, the existing "open in desktop slicer via URI" flow is the default and unchanged.

- **Per-spool category + low-stock threshold override** ([#729](https://github.com/maziggy/bambuddy/issues/729) — minimal version) — Two new fields on the spool form: a free-text **Category** (with autocomplete from categories already in use, so users naturally re-use "Production" instead of accidentally typing "production" / "prod") and a per-spool **Low-stock threshold (%)** override that defaults to the global setting if left blank. Powers the "I want to differentiate critical spools from prototype spools and alert at different thresholds" use case from the issue without taking on the full multi-tag taxonomy + auto-apply-rules + per-tag alert system the ticket originally proposed (which would have been ~5x the work for the same underlying value). Inventory page gains a Category filter chip — only renders once at least one spool carries a category, otherwise hidden so the chip row stays uncluttered. Low-stock counts in the stat-card and the "Low Stock" filter both honour the per-spool override (so a "Production" spool with override = 90% will count as low-stock at 80% remaining even when the global threshold is 20%). 50-char cap on category, 1-99% range on threshold (0 and 100 are both rejected as footguns). 9 new backend schema-validation tests covering the field defaults, partial-update behaviour, range/length rejection; 2 new frontend tests confirming the per-spool threshold pulls in spools the global threshold misses, and that the category filter chip stays hidden until at least one spool has a category. Localised across all 8 UI languages with full translations. The full multi-tag taxonomy from the original issue isn't going forward; if demand for it grows past the current 3 thumbs-up the design can layer on top of these fields without breakage.

- **Per-event ntfy priority** ([#990](https://github.com/maziggy/bambuddy/issues/990)) — ntfy supports a `Priority` header (1=min, 2=low, 3=default, 4=high, 5=urgent) that drives sound, visibility, and push behaviour on the receiving device, but the existing notifier sent every event at the server default — so a "50% complete" ping looked identical to "print failed" or "printer offline". The Add/Edit Notification modal now renders a per-event "ntfy Priority" section (visible only when the provider type is `ntfy`) listing each *enabled* event with its own Min / Low / Default / High / Urgent dropdown; selections persist into the provider's `config.event_priorities` map and the backend emits a matching `Priority: N` header on the ntfy POST/PUT request (including the image-attachment path). Events not explicitly mapped, malformed values, and out-of-range values (0, 6, "abc", null) all fall through to ntfy's server-side default — there is no clamping, so a misconfigured value never silently sends at the wrong urgency. Test sends (no `event_type` context) deliberately omit the header so the test path cannot accidentally page someone at urgent priority. Existing providers without `event_priorities` are untouched on upgrade. Localised across all 8 UI languages with full translations (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). 6 new backend tests covering header set on mapped event, omitted on unmapped event, omitted when no `event_priorities` configured, omitted when `event_type` is missing, ignored for out-of-range / non-numeric values, and propagated through the image-attachment PUT path.

- **Long-lived camera-stream tokens for HA / Frigate / kiosks** ([#1108](https://github.com/maziggy/bambuddy/issues/1108)) — The existing `?token=…` camera-stream tokens expire after 60 minutes which forced home-automation integrations (Home Assistant cards, Frigate, hallway kiosks) to either refresh on a cron or run with auth disabled. New self-service "Camera API Tokens" panel under **Settings → API Keys** (also reachable via the existing settings search box — type "camera token" / "frigate" / "home assistant") lets any user holding `camera:view` mint a long-lived token they can paste once and forget. Revoke uses Bambuddy's standard styled confirmation modal (no `window.confirm` browser default — same pattern as the rest of the app). Tokens are scoped strictly to camera streaming (no privilege escalation surface — no other endpoint accepts them), formatted `bblt_<8-char-prefix>_<32-char-secret>`, and stored as a pbkdf2 hash so even a DB dump can't replay them; the plaintext is shown to the user **exactly once** in a copy-to-clipboard modal (with a `document.execCommand('copy')` fallback for plain-HTTP LAN deployments where `navigator.clipboard` is gated by the secure-context requirement). Hard 365-day max — the issue's `expire_in: 0` (never) is explicitly rejected because an irrevocable infinite token is a footgun-by-design; UI defaults to 90 days, the cap is enforced both client-side (input clamp) and server-side (validation guard). Owners can revoke their own tokens; admins additionally see an "All users" view for leak triage and can revoke anyone's. The `/camera/stream?token=…` auth dependency tries the existing 60-min ephemeral row first (no behaviour change for the common browser case) and falls through to the long-lived path, so the SPA's existing camera flow is unaffected. Indexed `lookup_prefix` keeps verify O(1) per token even on large installs — pbkdf2 only runs against the one candidate row that matches the prefix, never the whole table. New `long_lived_tokens` table (separate from `auth_ephemeral_tokens` because the lifecycle is different — user-owned, named, revocable, hashed; and separate from `api_keys` because that one is for global webhooks with no user FK and a different permission shape). 15 unit tests covering create-validation/scope/expiry rules, verify happy/garbage/expired/revoked/scope-mismatch/prefix-collision paths, list-by-user vs list-all, idempotent revoke; 14 integration tests covering the create-once-then-listing-hides-plaintext contract, the 365-day cap, the auth gate, owner-vs-admin revoke ownership rules, and that the long-lived token verifies through the same camera-stream auth dependency the route uses (and that revoke immediately invalidates it). 6 frontend tests covering list render, empty state, create-then-shown-once flow, days-input clamp, revoke-with-confirm, and revoke-cancelled paths. New `cameraTokens.*` keys across all 8 locales (English fully translated; the seven other locales seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features).

- **Tailscale integration for virtual printers** (builds on [#1070](https://github.com/maziggy/bambuddy/issues/1070) by @legend813) — Opt-in per-VP Tailscale toggle brings each virtual printer into the tailnet, so it's reachable from any tailnet device over a private WireGuard tunnel without port forwarding or public exposure. When enabled, Bambuddy provisions a Let's Encrypt cert for the VP's MagicDNS hostname via `tailscale cert` and the MQTT/FTPS listeners serve it. **Slicer-side caveat worth knowing up front:** both Bambu Studio and OrcaSlicer only accept IP addresses (not hostnames) in the Add Printer dialog, so the LE cert's hostname validation doesn't apply — users still need the Bambuddy CA imported into the slicer, same as LAN mode. The practical benefit here is the private tunnel (remote access without DDNS / port forwarding / public exposure), not cert-import elimination. Default is opt-out (toggle off) so users without Tailscale don't see cert-provisioning attempts or log noise. When a user flips the toggle on a host without a working Tailscale binary, the backend returns `409 tailscale_not_available` and the UI reverts + surfaces a specific toast pointing at the setup steps (install Tailscale → `tailscale up` → `tailscale set --operator=<user>` → enable HTTPS in the tailnet admin console). Docker image now ships the `tailscale` CLI pre-installed; users wire up by uncommenting the `/var/run/tailscale/tailscaled.sock` volume mount in `docker-compose.yml`. The MagicDNS hostname is surfaced on the VP card with a copy-to-clipboard button (modern `navigator.clipboard` in secure contexts, `document.execCommand` fallback for plain-HTTP contexts with textarea cleanup in `finally`). Cert renewal runs daily in-process and restarts only the affected VP's TLS listeners. New i18n keys `virtualPrinter.tailscaleDisabled.{title,description}` + `virtualPrinter.toast.{tailscaleNotAvailable,copyFailed}` across all 8 locales with full translations. 3 new backend integration tests for the 409 guard, 2 unit tests for the `_cancel_restart_task` self-await guard, 4 unit tests for the settings-dedupe migration, and 3 new frontend tests for the clipboard fallback path. Thanks to @legend813 for the original opt-out toggle PR that this was built on top of.

- **Library Trash Bin + Admin Bulk Purge + Auto-Purge** ([#1008](https://github.com/maziggy/bambuddy/issues/1008)) — Library files now move to a trash bin on delete instead of being hard-deleted from disk, with a configurable retention window (default 30 days) before a background sweeper permanently removes them. Admins get a new "Purge old" action on the File Manager that shows a live preview of count + total size before moving every file older than *N* days (with an opt-in toggle for never-printed files, on by default) into the trash in one shot. A new **Auto-purge** setting in Settings → File Manager runs the same purge automatically on a 24-hour cadence when enabled — files still go to Trash first so the retention window remains the safety net; default-off so existing installs don't surprise anyone. Both the per-user delete flow and the admin bulk purge go through the same trash — regular users see and manage their own trashed files; admins see everyone's. External (linked) files bypass trash and keep the original hard-delete behaviour since their bytes aren't under Bambuddy's control. New `library:purge` permission gates the admin operations; retention is adjustable inline on the Trash page for admins. Adds nullable `deleted_at` column on `library_files` with an index (dialect-aware migration: `DATETIME` on SQLite, `TIMESTAMP` on PostgreSQL, since raw `DATETIME` is SQLite-only syntax); every `LibraryFile` query site now routes through a new `LibraryFile.active()` classmethod so trashed rows can't leak into listings, print dispatch, MakerWorld dedupe, or stats. 17 new backend integration tests + 8 new frontend component/page tests; localised across all 8 UI languages. Thanks to @cadtoolbox for the proposal and the follow-up answers that tightened the spec.
- **Archive Auto-Purge** ([#1008](https://github.com/maziggy/bambuddy/issues/1008) follow-up) — Settings → Archives now has an auto-purge toggle plus a **Purge archives now** action on the Archives page header (next to Upload 3MF, mirroring File Manager's placement) that hard-deletes print archives not printed within a configurable window (default 365 days, min 7, max 10 years) with the same live-preview modal as the library purge. Reprinting an archive reuses the row and updates its `completed_at`, so the purge honours the **most recent print completion** — a two-year-old archive you reprinted yesterday is not eligible for deletion. Unlike the library trash, archives are hard-deleted: print history is a decaying timeline, so there is no trash bin intermediate; download or favourite anything you want to keep first. The sweeper runs on the same 15-minute scheduler as the library trash but throttles actual purge runs to once per 24h so a tight tick cadence doesn't churn the DB. Each purged archive goes through the existing safety-checked `ArchiveService.delete_archive` path so the 3MF, thumbnail, timelapse, source 3MF, F3D, and photo folder are all cleaned up together with the DB row. Gated by a new dedicated `archives:purge` permission (Administrators group by default, backfilled on upgrade); 9 new backend integration tests; localised across all 8 UI languages.
- **MakerWorld Integration** — Paste any `makerworld.com/models/…` URL on the new MakerWorld sidebar page to pull the full model metadata, plate list, creator/license info, and per-plate images, then one-click **Save** or **Save & Slice in Bambu Studio / OrcaSlicer** per plate. Closes the last workflow gap for LAN-only users who still had to keep the Bambu Handy app installed solely to send MakerWorld models to their printers. Reuses the existing Bambu Cloud login token for download authentication — no separate OAuth flow, no companion browser extension, no cookie paste. `LibraryFile` now tracks `source_type` + `source_url`, so re-importing the same plate dedupes to the existing library entry. Search / browse-catalogue is intentionally out of scope because MakerWorld's public search endpoint isn't reachable from a server-originated request; the URL-paste flow covers the actual discovery pattern (Reddit / YouTube / shared links).
  **Endpoint route (non-obvious, ~1 day of reverse engineering)** — Pr0zak/YASTL#51 documented that `makerworld.com`-hosted design-service endpoints are cookie-gated (Cloudflare WAF serves a generic "Please log in to download models" to any non-browser bearer request), but the same backend is exposed unblocked at `api.bambulab.com`. The working path turned out to be `GET https://api.bambulab.com/v1/iot-service/api/user/profile/{profileId}?model_id={alphanumericModelId}` with `Authorization: Bearer <cloud_token>` — a different service (`iot-service`, not `design-service`) and a different host, accepting the same bearer the user already signs in with. Response carries a 5-minute-TTL presigned S3 URL (``s3.us-west-2.amazonaws.com/…?at=…&exp=…&key=…``). The `modelId` query param is the alphanumeric identifier (e.g. ``US2bb73b106683e5``) that only appears in the design response body, *not* the integer ``designId`` from the ``/models/{N}`` URL — so the import flow fetches design metadata first, reads `modelId`, then calls iot-service. S3 presigned URLs must be fetched with ``urllib.request`` (not httpx / curl_cffi) because the signature is computed over the exact query-string bytes and any normalising encoder breaks it with ``SignatureDoesNotMatch`` 400s (YASTL#52 describes the same issue). Every other published reverse-engineering project we evaluated (schwarztim/bambu-mcp, kata-kas/MMP) solved the gating by shipping "paste your browser cookie" flows; reusing the existing Bambu Cloud bearer is a substantially cleaner UX and the only fully-automated path.
  **UI and UX features** — per-plate picker with inline **Save** / **Save & Slice in Bambu Studio / OrcaSlicer** buttons, **Import all** to batch-import every plate sequentially, folder picker on the page (default: auto-created top-level "MakerWorld" folder), image gallery lightbox per plate (keyboard ←/→/Esc), two-column sticky layout with Recent imports sidebar (last 10 MakerWorld imports), per-plate inline follow-up actions after import (**View in File Manager** / **Open in Bambu Studio** / **Open in OrcaSlicer** / **Remove from library**), per-plate delete via the standard Bambuddy confirm modal (no browser `confirm()`), elapsed-time + phase label ("Resolving … 3 s", "Downloading … 18 s") during the synchronous import POST so users see progress on large 3MFs, URL-change detection that drops the preview when the pasted URL diverges from the resolved one (fixes a class of "I thought I was importing model B but got A" dedupe confusion), rich error toasts per-phase, and the slicer-open path reuses Bambuddy's existing token-embedded library download (`/library/files/{id}/dl/{token}/{filename}`) so the handoff works even with auth enabled. Localised across all eight UI languages.
  **Security hardening** — the MakerWorld description HTML is user-authored and goes through `DOMPurify.sanitize()` before `dangerouslySetInnerHTML`. `<img>` tags inside summaries are rewritten to route through Bambuddy's ``/makerworld/thumbnail`` proxy so the SPA's ``img-src 'self' data: blob:`` CSP stays unwidened. Thumbnail proxy now uses ``follow_redirects=False`` (the host-allowlist guarantee is only meaningful on the initial URL — a 302 to `169.254.169.254` would otherwise bypass it). The 3MF CDN fetch sends only `User-Agent` — the Bambu Cloud bearer is never forwarded to the CDN. S3 presigned-URL fetch uses a `urllib.request` opener with a no-op ``HTTPRedirectHandler`` for the same reason. Filenames from MakerWorld responses are `os.path.basename`'d before persisting, so a malicious ``name: "../../evil.3mf"`` cannot surface a path-traversal string into the DB / UI (on-disk storage uses a UUID filename regardless). New routes respect the `MAKERWORLD_VIEW` (resolve / recent-imports / status) and `MAKERWORLD_IMPORT` (import) permissions. SSRF guard on downloads rejects any host that isn't `makerworld.bblmw.com`, `public-cdn.bblmw.com`, or a `.amazonaws.com` subdomain.
  **Test coverage** — 46 unit tests for `services/makerworld.py` (header shape, API base, `get_design`/`get_design_instances`/`get_profile`, `get_profile_download` 200/401/403/404/no-token, `download_3mf` SSRF rejection of 4 hostile hosts, S3 path delegation, CDN path with minimal headers, size-cap, `_download_s3_urllib` happy/redirect/size/network paths, `fetch_thumbnail` with `follow_redirects=False`); 19 route tests (`/resolve`, `/import` with folder autocreation + explicit folder + dedupe + filename basename + profile_id response, `/recent-imports` with empty-list / ordering / pydantic shape / limit clamping, `_canonical_url` unit); 12 frontend tests (button labels, slicer-name interpolation, URL-change detection, inline post-import actions, Recent imports rendering, DOMPurify `<script>` strip).

- **SpoolBuddy kiosk no longer shows main-app toasts** — the global `ToastProvider` (in `App.tsx`) wraps both the main app routes and the SpoolBuddy kiosk routes, so the background-dispatch progress overlay (job percent, completion summaries, etc.) was rendering on the kiosk display alongside any in-flight prints. Added a `setViewportSuppressed` setter on the toast context; `SpoolBuddyLayout` flips it on mount and restores on unmount via a single `useEffect`. The state machine, dispatch-event subscription, and other tabs' toast UIs are untouched — only the visible viewport is hidden while a kiosk display is active. Trade-off accepted: kiosk-local one-shot toasts (plate-clear confirmation, quick-add errors) are also hidden, but the kiosk's UI already provides direct visual feedback (the plate-ready row vanishes on click; quick-add failures surface in the modal). Updated `SpoolBuddyLayout.test.tsx` to wrap in `ToastProvider` and expand its lucide-react mock with the icons ToastContext imports. 2 new regression tests: `ToastContext.test.tsx::viewport suppression` pins the suppressed-viewport `hidden` class toggle without affecting the underlying state, and `SpoolBuddyLayout.test.tsx::suppresses the global toast viewport while mounted` confirms the kiosk layout flips suppression at mount and cleanup.
- **Background-dispatch toast no longer reads as "frozen at 100%" for fast uploads** — small files (a few hundred KB to a printer over LAN) finish FTP upload in <500ms, so the progress bar would jump to 100% and then sit there for ~1-2s while the printer's MQTT confirmation landed and the success toast replaced the dispatch toast. Now, when the byte-count reaches the total but the job status is still `processing` (i.e. upload done, awaiting printer ack), the byte-count line is replaced with "Awaiting printer..." and the progress bar gets `animate-pulse` to indicate continued activity. Translated across all 8 locales (`backgroundDispatch.awaitingPrinter`). 2 new tests in `ToastContext.test.tsx::background dispatch — upload-done UX` cover the threshold (`uploadProgressPct >= 99.9` with `processing` status switches to "Awaiting printer..." + pulse) and the in-flight case (`50.0%` keeps the byte/percent counter, no pulse).
- **SpoolBuddy kiosk: "Plate ready" pills under the printer status badges** — when any printer reports `awaiting_plate_clear=true`, a small amber pill appears in the dashboard's left column, sized to match the existing online/offline printer badges. Each pill shows the printer name plus a "Clear" action; tapping it calls `POST /printers/{id}/clear-plate` and optimistically removes the pill from the UI before the WebSocket round-trip lands. Multi-printer setups (e.g. four H2Ds finishing at once) wrap inline via `flex-wrap` so the dashboard stays compact instead of pushing everything else off-screen. The kiosk's API key already passes the `printers:clear_plate` permission gate via the existing `_APIKEY_DENIED_PERMISSIONS` denylist (the permission is intentionally not denied — clear-plate is an inventory-flow operation, not an admin one), so no auth wiring changes were needed. Translated across all 8 UI languages (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). 5 new regression tests in `SpoolBuddyDashboard.test.tsx::plate-clear row` cover: row hidden when no printer is pending, mixed pending/non-pending printers (only the pending one gets a pill), title attr + pill text content + Clear label all rendered, clicking calls `api.clearPlate(printerId)`, the optimistic cache write makes the row vanish without waiting for a refetch, and three concurrent pending printers wrap inline in the same `flex-wrap` container. The mock `useTranslation` was upgraded to support `{{var}}` interpolation so future tests can assert on rendered i18n strings with arguments.
- **Per-request trace ID column on every log line, plumbed through HTTP access log + application logs + response headers** — Builds on the new uvicorn-access-log-into-bambuddy.log change below: the access line tells you *who* called an endpoint, but until now there was no way to tie that line to the application records emitted on the server side while handling that request. A new FastAPI middleware (`trace_id_middleware` in `main.py`, sourced from `backend.app.core.trace`) stamps each request with a fresh 8-char hex ID (or honours a sane inbound `X-Trace-Id` header for cross-system correlation), stores it in a `ContextVar` so any code in the request's call stack can read it, echoes it on the response as `X-Trace-Id`, and a new `TraceIDFilter` injects it into every `LogRecord` so the format string `[%(trace_id)s]` resolves to the right ID for the right request. ContextVars (rather than `request.state`) are the right plumbing here because asyncio copies the current context into every `asyncio.create_task`, so background work spawned from inside a request inherits the trace ID without explicit threading; the logging filter has no access to the FastAPI request object regardless. Records emitted outside any request scope (startup, MQTT callbacks, scheduler) get a stable `-` placeholder so the column stays visually aligned and missing values are obvious in `grep`. Inbound `X-Trace-Id` is hard-validated against a strict whitelist (`[A-Za-z0-9_-]+`, max 64 chars) before being honoured — a hostile or buggy caller cannot smuggle log-injection payloads (newlines, control chars, megabyte blobs) into `bambuddy.log` via the trace-ID column; values that fail the gate silently trigger a freshly minted server-side ID rather than failing the request. Middleware is decorated AFTER `auth_middleware` on purpose: Starlette stacks `@app.middleware` decorators LIFO so the last-decorated runs first inbound, making trace stamp the OUTERMOST layer — auth log lines and every record emitted on the way down to and back from the route handler all carry the same ID. Output now looks like `2026-04-26 09:51:39,152 INFO [uvicorn.access] [a4f3b1e7] 192.168.1.42:54812 - "POST /api/v1/printers/1/print/stop HTTP/1.1" 200` paired with the route handler's `2026-04-26 09:51:39,158 INFO [bambu_mqtt] [a4f3b1e7] [SERIAL] Sent stop print command` — one `grep a4f3b1e7` away from the full causality chain. 30 new tests across `tests/unit/test_trace.py` (placeholder when no request scope, filter copies ContextVar value onto records, ID propagates into spawned tasks via asyncio context copy, concurrent requests don't leak IDs into each other, generator produces unique hex IDs, hostile payloads rejected by validator, max-length boundary, dash/underscore variants accepted) plus `tests/integration/test_trace_middleware.py` (X-Trace-Id header echoed on response, body and header IDs match, each request gets a unique ID, generator format stays short hex, safe inbound IDs honoured, hostile inbound IDs replaced, overlong inbound IDs replaced, ContextVar reset cleanly after request).

### Changed
- **AMS slot "Assign to inventory spool" picker now lists every spool, including RFID-tagged Bambu Lab ones** ([#1133](https://github.com/maziggy/bambuddy/issues/1133)) — The picker that opens from `<FilamentHoverCard>` / SpoolBuddy's slot-action sheet had two stacked filters that together blocked a real workflow: (1) `AssignSpoolModal` only listed spools whose `tag_uid` AND `tray_uuid` were both null, hiding any Bambu Lab spool that had been auto-created from RFID or scanned via SpoolBuddy NFC; (2) `FilamentHoverCard` rendered its inventory section (assign + unassign affordances) only when the slot's vendor was *not* `Bambu Lab`, so even if you fixed the picker the button to open it wasn't visible on a BL slot. The use case both filters blocked: a user who has a Bambu Lab spool sitting in their inventory but doesn't want to scan it via SpoolBuddy NFC each time and just wants to pick it from the list. Both gates are gone now: the modal lists every spool that isn't already taken by a different (printer / ams_id / tray_id) tuple, and the hover-card inventory section renders for every vendor including Bambu Lab. The AMS-vs-external-slot distinction in the modal also collapsed — external slots (amsId 254/255) used to be the only path that allowed picking a tagged spool, and that special-case is now redundant. Empty slots (`<EmptySlotHoverCard>` in Bambuddy, `slotActionPicker.tray === null` in SpoolBuddy) lost their assign affordance entirely: a physically empty slot has no spool to attach an inventory record to, and offering the action there only led to users assigning the wrong spool to a slot the printer hadn't actually loaded yet — assignment now requires a loaded slot. The `i18n.inventory.noManualSpools` key (whose copy talked specifically about "manually added spools") was renamed to `inventory.noAvailableSpools` with new copy ("No spools available. Add a spool to your inventory or unassign one from another slot first.") since the empty-state premise changed; localised across all 8 languages with full translations. 5 net-new frontend tests in `__tests__/components/FilamentHoverCard.test.tsx` (assign/unassign buttons render for `vendor: 'Bambu Lab'`, non-BL vendors unchanged, EmptySlotHoverCard renders no assign affordance, configure button still works on empty slots) plus the existing `AssignSpoolModal.test.tsx` "filters out BL spools" expectation was inverted to match the new contract and the empty-state test reworked to exercise the only remaining trigger (every spool taken by another slot).
- **Inventory: "Delete Tag" button renamed to "Clear RFID Tag"** ([#729](https://github.com/maziggy/bambuddy/issues/729) follow-up) — The reporter mistook the button for a taxonomy-tag delete (it actually clears the RFID tag UID/UUID off the spool record so the row can be re-attached to a different physical spool). Renaming it to "Clear RFID Tag" + the success toast to "RFID tag cleared" removes the ambiguity. No behaviour change. Localised across all 8 UI languages with full translations.

- **Nozzle icon on the dual-nozzle status card** ([#1115](https://github.com/maziggy/bambuddy/issues/1115)) — the dual-nozzle active-extruder card on the printer status bar was the only card in that row without a theme icon (the Nozzle/Bed/Chamber temperature cards all carry a thermometer icon), which left the row looking visually uneven on H2D / H2S / H2C. Adds a small schematic nozzle icon (filament body + heater block + tip) above the L/R diameter labels, styled in amber-400 to match the card's active-extruder accent. SVG design contributed by @m4rtini2.
- **Slice tracker no longer shows the "embedded settings used" warning toast** — `SliceJobTrackerContext` was emitting a yellow warning toast on every completed slice whose result carried `used_embedded_settings: true` (the auto-fallback path that fires when the sidecar's `--load-settings` triplet rejected the input). For 3MF inputs that fallback fires on essentially every slice in production (BambuStudio CLI segfaults silently on `--load-settings` over 3MF, even with the broader strip applied — verified end-to-end with the new sidecar stderr capture), so the toast was firing on essentially every completed slice and adding noise without a useful action. The `used_embedded_settings` flag still lands on `SliceResponse` / `SliceArchiveResponse` for tests + observability (`test_library_slice_api.py:347` continues to pin it); only the user-facing toast goes. `slice.fallbackUsedEmbedded` removed from all 8 locale files in the same change.

- **Settings page: permission-gated instead of admin-only** — the Settings sidebar entry has always been visible to any user holding `settings:read`, but the route guard required admin role, so a non-admin with `settings:read` would see the entry, click it, and get silently redirected back to the dashboard. The route guard now matches the sidebar: any user with `settings:read` can open the page, and the individual tabs / cards continue to enforce their own per-feature permissions (`users:read`, `groups:update`, `oidc:*`, etc. — many of them admin-only, some not). Group editor routes moved to permission-based guards too (`groups:create` for `/groups/new`, `groups:update` for `/groups/:id/edit`), so permission delegation works end-to-end. Admins retain full access since admins implicitly hold every permission.
- **i18n: full key parity across all 8 locales** — `en` is the reference; every other locale (`de`, `fr`, `it`, `ja`, `pt-BR`, `zh-CN`, `zh-TW`) is checked identically and any drift fails CI. Until now, the parity script at `frontend/scripts/check-i18n-parity.mjs` only enforced parity for `de` / `zh-CN` / `zh-TW` and demoted `fr` / `it` / `ja` / `pt-BR` to an "informational" tier — drift was reported but never gated. Result: 78 missing keys in fr and it, 66 in pt-BR, 54 in ja, accumulated across every release that added new en strings. Backfilled real translations (not English fallbacks) for every gap: `login.resetPassword.*` (12 keys, fr/it/ja), `printers.firmwareModal.*` extension (7 keys × 4 locales) from the firmware modal redesign, the full `settings.spoolbuddy.*` device-control admin block (~40 keys) for unregister / reboot / shutdown / update / restart confirms, the kiosk-side `spoolbuddy.settings.*` block (13 keys × 4) for backend & auth + diagnostics, and the new `virtualPrinter.archiveNameSource.*` block from this release ([#1152](https://github.com/maziggy/bambuddy/issues/1152)). The parity script itself dropped the two-tier `STRICT` / `info` machinery — every non-en locale is now treated equally — so any future feature that adds en strings without translating them everywhere fails CI uniformly. All 8 locales sit at 4492 leaves.

### Fixed
- **In-app upgrade was hardcoded to `origin/main` and silently no-op'd whenever the latest release wasn't on main** — `_perform_update` ran `git fetch origin main && git reset --hard origin/main` verbatim, regardless of which version GitHub's releases API reported as latest. So during any beta release cycle (when `0.2.4b1` lives on its own branch and `main` still points at the previous stable), users on the prior stable who clicked *Apply Update* saw the GUI report success but actually stayed pinned to the old `main` HEAD. The pre-existing pip-cwd and SSH-origin-clobber bugs in this same code path made it worse, but the underlying limitation was that the updater literally couldn't reach a non-main release. Fix: extract `_discover_target_release(db)` (mirrors the same release-API + `include_beta_updates` selection logic the GUI's update-check route already uses), pass the resolved tag (e.g. `v0.2.4b1`) into `_perform_update(target_ref)`, and `git fetch --prune --tags origin && git reset --hard <tag>`. The fetch step now pulls `--tags` so the tag ref is locally resolvable; the reset takes whatever ref the caller resolved instead of a hardcoded branch. Also makes `apply_update` return a clear error if no release matches the user's channel rather than silently kicking off an update that can't land. Three new regression tests in `test_updates_api.py` cover (1) `_perform_update` resets to the caller-supplied ref and fetches tags, (2) `apply_update` plumbs the discovered tag through to `_perform_update`, (3) `apply_update` errors out cleanly when discovery returns no candidate.
- **In-app upgrade clobbered SSH `origin` on developer checkouts** — The in-app *Apply Update* path unconditionally ran `git remote set-url origin https://github.com/maziggy/bambuddy.git` before fetching, on the assumption that systemd service users wouldn't have SSH keys configured. That assumption holds for production native installs, but anyone testing the upgrade flow against their own development checkout (where `origin` is legitimately `git@github.com:maziggy/bambuddy.git` and authentication is via SSH keys) had their SSH origin silently rewritten to HTTPS — so the very next `git push` prompted for HTTPS credentials they didn't have configured and bounced. Fix: the updater now reads the current `origin` first via `git remote get-url`, parses the URL into an `(owner, repo)` pair (handling all four canonical forms — `git@github.com:owner/repo[.git]` and `https://github.com/owner/repo[.git]`), and only rewrites if it doesn't already resolve to `maziggy/bambuddy`. Native installs with no remote set, or origins pointing at a fork / wrong repo, still get reset to the canonical HTTPS URL. Three regression tests in `test_updates_api.py` cover the parser, the SSH-preservation case, and the fork-rewrite case so a future refactor can't regress either side of the contract.
- **Native-install in-app upgrade silently skipped `pip install` and the new dependencies never landed** — On a native install (where systemd sets `DATA_DIR=$INSTALL_PATH/data`), the in-app *Apply Update* button shipped the new code via `git reset --hard origin/main` correctly but then logged `ERROR: Could not open requirements file: [Errno 2] No such file or directory: 'requirements.txt'` and continued without installing the new deps. `pip install -r requirements.txt` was running with `cwd=settings.base_dir`, which on a native install resolves to the data dir (e.g. `/opt/bambuddy/data`), not the source-code dir (`/opt/bambuddy`); pip doesn't walk up looking for the requirements file the way `git` walks up looking for `.git`, so the file wasn't found, the install was effectively skipped, and the user ended up with new code but stale dependencies — which surfaces as cryptic import / runtime errors on the next restart. Same bug affected the optional `npm install` / `npm run build` step (it tested `frontend_dir = base_dir / "frontend"`, which doesn't exist on native installs, and silently fell through to the pre-built static files). Fix: introduce `settings.app_dir` alongside `settings.base_dir` pointing at the source-tree root, and run `pip install` and the npm steps with `cwd=settings.app_dir`. Git operations keep using `base_dir` since they already worked (git walks up to find `.git`). Docker users were unaffected — Docker doesn't use the in-app updater (image pull replaces it). Regression test in `test_updates_api.py` mocks every subprocess invocation in `_perform_update`, captures their cwd, and asserts the pip step runs in `app_dir` and that `requirements.txt` actually exists there, so a future refactor that re-introduces `cwd=base_dir` for the pip step fails CI before another user trips over it.
- **Postgres restore from a SQLite Local Backup aborted with `cannot drop table printers`** — Settings → Backup → Restore on a Bambuddy running against external Postgres failed with `asyncpg.exceptions.DependentObjectsStillExistError: cannot drop table printers because other objects depend on it` whenever the live database carried orphan tables from removed features — for example legacy `spoolman_slot_assignments` / `spoolman_k_profile` from an earlier Spoolman integration that has since been removed from the ORM but whose tables and `*_printer_id_fkey` constraints still sat in the live schema, pointing at `printers`. The restore path (`_import_sqlite_to_postgres` in `settings.py`) called `metadata.drop_all`, which only enumerates tables defined by SQLAlchemy ORM models and emits plain `DROP TABLE` (no `CASCADE`); Postgres correctly refused to drop `printers` while external constraints still referenced it, the entire restore aborted before any rows landed, and the user was left without a working DB. The drop phase now executes `DROP TABLE … CASCADE` on every table in the `public` schema (via a `pg_tables`-iterating PL/pgSQL `DO` block, after FKs have been stripped from the ORM metadata) before `metadata.create_all` rebuilds the schema. CASCADE is the right tool for a destructive restore — the user has explicitly chosen to wipe the DB and replace it from backup, so taking out orphan tables alongside ORM tables is correct behaviour, not surprise data loss. SQLite restores are unaffected (they go through a separate path). Discovered while attempting to restore a 0.2.4b1 backup onto a Postgres instance that had been upgraded across the Spoolman integration rewrite. Two regression tests in `test_postgres_restore_drop_cascade.py` mock the Postgres engine, run `_import_sqlite_to_postgres` against a tiny SQLite source, and assert (1) the captured SQL stream contains a CASCADE-aware iteration over `pg_tables` (so a regression to `metadata.drop_all` fails CI loudly, before another user trips on it) and (2) the CASCADE drop is scoped to `schemaname = 'public'` so a shared Postgres instance holding non-Bambuddy data in other schemas isn't taken out by a restore. All 44 existing settings-API tests still pass unchanged.
- **H2D Pro multi-plate dispatch double-/triple-fire** ([#1157](https://github.com/maziggy/bambuddy/issues/1157)) — Scheduling 3 plates of a multi-plate file to the same H2D Pro caused the scheduler to fire all three `project_file` commands within ~60 seconds, even though the printer hadn't transitioned out of `FINISH` for the first one yet. The H2D Pro can sit at `FINISH` for 80–210 s after accepting `project_file` before the `gcode_state` flips to `PREPARE`, and during that window the existing DB `busy_printers` seed (querying queue items in `printing` status) was empirically missing the in-flight item — observed in support logs as items 139/140/141 all dispatching with status='printing' yet only the third actually triggering a state transition. User-visible symptoms: layer count flapping, all queued plates showing as printing simultaneously, MQTT disconnect storms (33 in a single 5-minute window), eventual print failure. Root-cause fix is a defensive in-memory dispatch hold layer in `print_scheduler.py`: when `_start_print` succeeds we record `(printer_id, dispatched_at, pre_state, pre_subtask_id)`, and the next `check_queue` tick adds that printer to `busy_printers` until either (a) the watchdog observes a state/subtask transition (success path — release immediately past a 60 s minimum cooldown), or (b) a 180 s hard timeout expires (escape hatch for lost MQTT sessions). The minimum cooldown also prevents a spurious double-dispatch if the printer pulses through PREPARE→RUNNING→PREPARE in the first second after acceptance. The hold is purely additive — sits alongside the existing seed query and `_is_printer_idle` checks, doesn't depend on DB row visibility, doesn't depend on `on_print_complete` firing correctly. Per-printer isolation: a hold on printer A never blocks printer B. Edge cases covered by 12 new unit tests (`test_scheduler_dispatch_hold.py`): no-pre-state fallback (printer was offline at dispatch time), status-unavailable keeps hold (printer disconnected post-dispatch — don't release on missing data), idempotent release, hard-timeout self-cleanup, transition-during-cooldown still holds. The 90 s watchdog still owns the unhappy-path revert (queue item back to `pending` for retry) — this fix runs alongside it, not instead of it. All 179 existing scheduler tests still pass unchanged.

- **Project picker UX in archives** ([#1151](https://github.com/maziggy/bambuddy/issues/1151)) — The "Add to Project" submenu in the archive context menu was unusable past the visible fold once a project library exceeded the 300px scroll cap: any wheel scroll, arrow-key navigation, or scrollbar click slammed the entire context menu shut. Root cause was a capture-phase `document.scroll` listener in `ContextMenu` that fired on internal submenu scrolls too — the listener now checks `menuRef.current.contains(e.target)` and ignores scrolls inside its own subtree. Project lists are now sorted alphabetically by name (`localeCompare`) at every assignment site (Archives context-menu submenu ×2, BatchProjectModal, EditArchiveModal, "review new uploads" panel, FileManagerPage project-picker) instead of newest-first from the API. The Archives "Add to Project" submenu and BatchProjectModal both gain a search input (rendered only when there are >5 projects, so small libraries stay clean) that filters the list by name as you type — Enter picks the first match. New `archives.menu.searchProjects` i18n key in all 8 locales (en/de fully translated, the six others seeded with English copies pending native translation, matching the project's existing flow).

- **OIDC `auto_link_existing_accounts` now works with custom email claims (Azure Entra ID)** ([#1088](https://github.com/maziggy/bambuddy/issues/1088)) — `auto_link_existing_accounts` was previously blocked unless both `email_claim='email'` and `require_email_verified=True`. This also rejected Azure Entra ID configurations using `preferred_username` or `upn` as the email claim — the recommended setup for that provider, which does not send `email_verified`. The guard now only blocks the genuinely unsafe combination (Fall B): `email_claim='email'` + `require_email_verified=False`. Custom-claim configurations (Fall C) never consult `email_verified` at all, so there is no verification-bypass risk on that path. All five enforcement layers (DB CHECK constraint, schema validators for create and update, route combined-state guard, DB migration for existing installations) have been updated consistently. **Security note:** custom claims are safe for auto-link only when the claim value is tenant-administered. If your IdP allows end users to self-assert the claim's value, do not enable auto-link. An in-app warning is shown in the OIDC provider form when this combination is configured.
- **OIDC settings form: "Require email verified" toggle no longer jumps layout when auto-link is enabled** — When `Auto-link existing accounts` was toggled on, the shorter description text caused the `Require email verified` toggle to reflow next to `Auto-link` in the flex container instead of staying on its own row. Both toggles now have `w-full` and always occupy a full row regardless of description length.
- **P1P print dispatch failed with `0500_4003 "can't parse print file"` when the printer was slow to acknowledge** ([#1150](https://github.com/maziggy/bambuddy/issues/1150), reported by @d3ni3) — On a P1P at firmware 01.10.00.00 the printer can take up to ~135 seconds to actually start parsing a freshly uploaded `.3mf` after the MQTT `project_file` command lands; FTP STOR returns 226 cleanly and the upload is intact, but `gcode_state` stays at `IDLE` and `subtask_id` doesn't advance until the printer's slow internal parse completes. Both dispatch watchdogs (`_verify_print_response` in `background_dispatch.py` and `_watchdog_print_start` in `print_scheduler.py`) interpreted the missed transition as a half-broken MQTT session — the original #887/#936 condition where telemetry kept arriving but our publishes were silently swallowed — and called `force_reconnect_stale_session` to wipe paho's QoS-1 queue and reconnect with a fresh client_id. That reconnect mid-parse is precisely what makes the P1P emit `0500_4003`: the new MQTT session interrupts the in-progress parse on the printer side and the printer reports the file as unparseable. The repro: send a print job, wait 15 seconds while the printer is still parsing, watch the watchdog force-reconnect, watch the printer fail with the parse error, retry — same loop. Sending the same file from BambuStudio worked because BambuStudio doesn't reconnect MQTT mid-parse. The fix uses the printer's `gcode_file` field as a definitive discriminator between #1150 (slow parse) and #887/#936 (half-broken session), since both look identical from telemetry alone: in both cases push_status keeps flowing, `state` stays unchanged, and `subtask_id` stays at the pre-dispatch value. The distinguishing signal: when the project_file command actually lands on the printer side, the printer's `gcode_file` field updates in push_status to reflect the newly-uploaded file; if the publish was silently swallowed (#887/#936), the field stays at whatever the printer was previously showing. Both watchdogs now capture `pre_gcode_file` alongside `pre_state` and `pre_subtask_id` from `printer_manager.get_status()` before sending the publish, then compare against the printer's current `gcode_file` after the watchdog times out. If the value changed → command landed → log a `#1150` warning explaining the skip and leave the MQTT session alone. If the value is unchanged → publish was silently swallowed → fall through to the original `force_reconnect_stale_session` call so the #887/#936/#1136 zombie-session recovery is preserved exactly. The user-facing dispatch still fails on timeout (correctly — the print didn't start within the timeout window so the job is marked failed), the queue item still reverts to `pending` so the scheduler can retry, and the next dispatch attempt proceeds against the same intact MQTT session that was about to start the print. Pairs with the 15s → 90s timeout bump that already shipped in commit 9d041868 (the original 15s timeout was a separate v0.2.3.2 limit). Caveat acknowledged in code comments: in a retry-same-file slow-parse scenario the printer's `gcode_file` looks identical before and after the publish lands, so the watchdog falls through to the original reconnect path and the user still sees `0500_4003` on that specific retry — accepted to avoid breaking the half-broken-session recovery, which is the more impactful regression of the two. 4 new unit tests covering both watchdogs: skip reconnect when `gcode_file` changed (the #1150 fix), reconnect when `gcode_file` is unchanged (the #936 protection preserved), skip reconnect when `pre_gcode_file=None` and current is non-None (printer just connected), reconnect when `pre_gcode_file` arg is omitted (backward-compat for callers we haven't updated). All 439 existing dispatch / scheduler / mqtt tests still pass unchanged.

- **3MF profile-driven slicing silently produced wrong-printer output (every 3MF slice fell back to the source's embedded printer regardless of the picked profile)** — Two stacked bugs in the slice pipeline. **(1) Pre-forward strip removed too much.** `_strip_3mf_embedded_settings` was scrubbing all four embedded `Metadata/*.config` files before forwarding the 3MF to the sidecar, on the theory that `--load-settings` would then take precedence cleanly. That theory was wrong: `Metadata/model_settings.config` carries the plate definitions the CLI needs to map `--slice N` to a real plate, and `slice_info.config` / `project_settings.config` supply baseline config the CLI's `StaticPrintConfigs` pass needs to even start. Stripping any of them caused the CLI to silently exit immediately after "Initializing StaticPrintConfigs" — exit code 0, no `result.json`, no stderr — which the sidecar treated as failure and Bambuddy then masked by falling back to `slice_without_profiles` using the un-stripped bytes (and the source's embedded printer). Net effect: every 3MF slice with profiles silently produced wrong-printer output. The strip is now gone from the slicer dispatch path entirely; original bytes go to the sidecar so `--load-settings` overrides only the specific fields the user changed (printer/process/filament) while the embedded plate / model definitions remain intact. **(2) Standard-tier preset stubs were missing the `type` field.** `_resolve_standard` in `preset_resolver.py` emitted `{"name": ..., "inherits": ..., "from": "system"}` for the bundled tier, but the CLI's preset parser also requires a `type` discriminator (`machine` / `process` / `filament`) on every loaded settings file — without it the CLI silently rejects with `rc=-5` ("input preset file is invalid"), which the same masking fallback then turned into another wrong-printer slice. New `_SLOT_TO_PROFILE_TYPE` constant maps each slot to its required type, and the stub now emits the right value per slot. **Tests**: integration test renamed from "strip removes all four configs" to `test_3mf_input_forwarded_unmodified_to_sidecar` — asserts every `Metadata/*.config` plus `3D/3dmodel.model` is preserved verbatim in the multipart body the sidecar receives. Preset-resolver test updated for the new stub shape; new `test_standard_emits_correct_type_per_slot` pins each (slot → type) pairing. Pairs with the orca-slicer-api fork's `bambuddy/profile-resolver` branch which now emits `details` on its `AppError` responses and captures CLI stdout/stderr in the failure path so future regressions of this shape produce a real error message instead of a silent fallback.

- **Sliced-archive card listed every project-wide AMS slot instead of just the filaments the print actually used** — `slice_and_persist_as_archive` previously copied `filament_type` / `filament_color` from the unsliced source archive verbatim, which inherited every project-wide AMS slot configured in the source's `project_settings.config` (16+ swatches on the card for what was actually a 2-color print). The new archive row now reads those fields from the sliced output's `Metadata/slice_info.config` via `ThreeMFParser` (which already gates on `used_g > 0` per-slot), falling back to the source archive's values only when parsing the new 3MF failed. Test in `test_archive_copy.py::test_filament_metadata_only_includes_filaments_with_used_g` builds a 4-slot fixture where slots 2 and 4 have `used_g=0` and asserts both type and color outputs exclude them.

- **Slice modal had no warning when the picked printer profile didn't match the source 3MF's bound printer — silent wrong-printer output** — Both BambuStudio and OrcaSlicer CLIs reject `--load-settings` for a printer different from the one the source 3MF was originally bound to (`rc=-16` "current 3mf file not support the new printer") because the cross-printer "convert project" flow is desktop-Studio only; the slice would then fall back to embedded settings and produce a file sliced for the wrong printer that errored at print dispatch time with "File was sliced for A1, but printing on H2D". The plates response now exposes `source_printer_model` (read from `project_settings.config`'s `printer_model` field, with fallback to stripping the nozzle suffix off `printer_settings_id`); the SliceModal compares it against the picked printer profile name (substring match against the model prefix, e.g. `"Bambu Lab H2D 0.4 nozzle"` matches `"H2D"`) and surfaces an inline amber warning explaining the limitation, plus disables the Slice button while the warning is up so users can't dispatch a guaranteed-wrong slice. Cloud presets with arbitrary user-chosen names (e.g. `"My Custom X1C"`) and legacy 3MFs without `project_settings.printer_model` fall through to no-warning, which is a reasonable default — the user picked it knowingly. New `extract_source_printer_model_from_3mf` helper in `threemf_tools.py` with 6 unit tests covering missing/direct/nozzle-stripped/corrupt-JSON paths; 3 frontend tests in SliceModal pinning the warning + disabled-button on mismatch, no-warning on match, and no-warning when the source model is unknown. New i18n key `slice.printerMismatch` localised across all 8 UI languages.

- **Sliced output of a "single-color" plate had filaments the user never picked** — When a multi-color project (e.g. a MakerWorld Stormtrooper helmet with white shell + grey support filament configured project-wide) was sliced for plate 1 (which only paints with white), the resulting `.gcode.3mf`'s `slice_info.config` had two filaments — white (the user's pick) and grey (a colour the user never chose). Root cause: the SliceModal was sending only the slots the picked plate consumed, but the slicer CLI requires a profile per project AMS slot — when fewer were supplied, the CLI silently substituted the missing slots from the source 3MF's embedded filament metadata, leaking the original creator's grey support filament into the user's output. Same silent-fallback class as the strip-removal bug. Fix: backend's `/filament-requirements` endpoint now returns the FULL project AMS slot list with a `used_in_plate: bool` flag per entry (computed from the cached preview slice for unsliced files; always `true` for sliced files since `slice_info.config` already pre-filters by `used_g > 0`). The SliceModal renders one dropdown per project slot — slots flagged `used_in_plate=true` are editable as before, slots flagged `used_in_plate=false` are auto-picked from project metadata via the existing `(filament_type, filament_colour)` scoring path and disabled with a "— not used by this plate" suffix on the label, so the user only interacts with what matters for their plate while the wire format always carries a profile per project slot. 2 new frontend tests pin the disabled-row rendering and the full-list-on-submit invariant. New i18n key `slice.notUsedByPlate` localised across all 8 UI languages (English + German fully translated, the six others seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features).

- **"Analyzing plate filaments…" spinner gave no signal that anything was happening on the first Slice-modal open for an unsliced project file** — On a multi-color 3MF without slice_info data, the backend runs a preview slice via the sidecar to discover which AMS slots the picked plate actually consumes. That's the only source of truth: tried two heuristics — painted-face quadtree scan (silently missed extruders when `object_id` mapping between `model_settings.config` and `3D/3dmodel.model` diverged, surfaced as a single dropdown for a 4-color print) and project-wide AMS list (over-rendered every plate to the project's full slot count) — and both produced wrong counts on real-world multi-color projects. Reverted to preview-slice-as-source-of-truth. The result is cached per `(kind, source_id, plate_id, content_hash)` so re-opens of the same plate are instant, but the first open on a complex model is a real slice (multi-second to multi-minute). The inline spinner now shows elapsed seconds and, after 5s, a hint explaining that this is a one-time preview slice and re-opens will be instant — addresses the original "is anything happening?" complaint without sacrificing correctness. Project-wide `extract_project_filaments_from_3mf` remains as a final fallback when the sidecar isn't configured. New i18n key `slice.analyzingPlateFilamentsHint` localised across all 8 UI languages (English + German fully translated, the six others seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features).

- **Settings warning when OrcaSlicer is selected as the preferred slicer** — OrcaSlicer 2.3.2 and 2.4.0-dev (latest nightly as of 2026-04-28) have two upstream CLI bugs that together block slicing on most Bambu-authored multi-color / H2D 3MFs: (1) a SIGSEGV in the multi-extruder filament-resolution path on painted 3MFs ([OrcaSlicer/OrcaSlicer#12426](https://github.com/OrcaSlicer/OrcaSlicer/issues/12426)), and (2) the CLI strict-validates parameter values that BambuStudio writes by default — `solid_infill_filament: 0`, `tree_support_wall_count: -1`, `prime_tower_brim_width: -1` — and exits 238 with `Param values in 3mf/config error: ... not in range`, even though OrcaSlicer's own GUI tolerates these ([OrcaSlicer/OrcaSlicer#13386](https://github.com/OrcaSlicer/OrcaSlicer/issues/13386), filed alongside this change with a minimal repro 3MF). Both bugs verified reproducible on the latest nightly build before filing. Settings → Workflow → Slicer card now renders an inline amber alert under the preferred-slicer dropdown when `orcaslicer` is the current selection, linking out to both upstream issues and recommending Bambu Studio until upstream fixes land. The OrcaSlicer option is intentionally left pickable rather than disabled — users who only slice STLs or single-color 3MFs aren't affected by either bug, and forcibly disabling would also affect them. Localised across all 8 UI languages (English + German fully translated).

- **Live progress for the SliceModal's filament-analysis preview slice + URL-decoded filenames in the toast** — Two follow-ups to the live slicer-progress feature: (1) the modal's "Analyzing plate filaments…" preview slice (the real slice that fires before profile picking, to discover which AMS slots an unsliced plate consumes) now shows the same stage + percent live updates as the user-initiated slice. The frontend generates a per-(source, plate) request_id, forwards it via a new `request_id` query param on `/library/files/.../filament-requirements` and `/archives/.../filament-requirements`, the backend plumbs it through `slice_without_profiles` to the sidecar, and a new `GET /api/v1/slicer/preview-progress/{request_id}` proxy endpoint forwards browser polls to the sidecar's `/slice/progress/:requestId` (CORS-safe — the browser can't reach the sidecar directly). The inline spinner and a new persistent toast both render `Analyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}` while the preview runs; toast dismisses when filaments arrive. (2) MakerWorld imports were persisting URL-encoded filenames (`stormtrooper-helmet%20h2d.3mf`) verbatim because MakerWorld's API returns the same percent-encoding it uses on its CDN URLs. The import path now `urllib.parse.unquote`s both the manifest-supplied name and the URL path-tail fallback before passing to `save_3mf_bytes_to_library`, plus the frontend defensively `decodeURIComponent`s in the slice toast and analysis-spinner messages so already-imported rows display cleanly without a backfill migration. Falls back to the raw string on malformed encodings (`%XY` where `XY` isn't hex). New i18n keys `slice.previewToast` + `slice.previewWithProgress` localised across all 8 UI languages (English + German fully translated).

- **Live slicer progress in the persistent slice toast** — The persistent slice toast already showed elapsed time + a spinner so the user could see the slice was still running, but for long slices on complex multi-color models that "is anything happening?" gap could last minutes. Bambuddy now wires up the slicer CLI's structured progress channel end-to-end, so the toast renders concrete stage labels + live percent — `Stormtrooper.3mf — Generating G-code (75%) — 47s` — through the entire slice. **Sidecar (`bambuddy/profile-resolver` branch of orca-slicer-api)**: switched the sync `/slice` route from `execFile` to `spawn` so the process can run alongside an FIFO reader; on each request the route generates (or accepts a caller-supplied) `requestId`, `mkfifo`s `${workdir}/progress.fifo`, passes `--pipe ${fifo}` to the OrcaSlicer / BambuStudio CLI, and reads the structured JSON-line progress events the slicer emits (`{"message":"Generating G-code","plate_count":1,"plate_index":1,"plate_percent":80,"total_percent":75}`) into a per-process `ProgressStore` keyed by `requestId`. New `GET /slice/progress/:requestId` returns the latest snapshot; entries linger 30s after slice completion so the caller's last poll still reads the terminal "All done, Success" frame instead of a 404. Both slicer forks share the same code lineage from PrusaSlicer's `BackgroundSlicingProcess`, so OrcaSlicer 2.3.2 and BambuStudio 02.06.00.51 emit identical JSON keys (verified by tracing the binary). **Bambuddy backend**: `slicer_api.slice_with_profiles` accepts `request_id` + `on_progress` callback and spawns a 1Hz parallel poller that hits the sidecar's progress endpoint while the blocking POST is in flight; `SliceDispatchService` gained a `set_progress(job_id, snapshot)` method and a `progress` field on `SliceJob`; the slice routes now generate a uuid `request_id` and wire a callback that forwards each snapshot onto the dispatcher. `GET /slice-jobs/:id` includes `progress` on every poll. **Frontend**: `SliceJobTrackerContext` reads the new `progress` field and re-renders the persistent toast with `{name} — {stage} ({percent}%) — {elapsed}` whenever a useful frame is present, falling back to the existing elapsed-time-only message when the sidecar hasn't emitted anything yet (early "Initializing" phase) or doesn't support progress (older sidecars without the FIFO wiring). 12 sidecar unit tests for the JSON-line parser + ProgressStore (cancellation/grace-window, malformed lines, missing fields), 3 dispatcher tests for `set_progress` (attach/replace/clear, unknown-job-id silent ignore), 3 slicer_api tests for the form-field forwarding + on_progress callback wire-up + 404 short-circuit, 2 frontend SliceJobTracker tests pinning the new toast format and the no-progress fallback. New i18n key `slice.runningWithProgress` localised across all 8 UI languages (English + German fully translated, the six others seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features). Graceful when the sidecar lacks `--pipe` support (tested live: OrcaSlicer 2.3.2 + BambuStudio 02.06.00.51 both work; older sidecars without the new endpoint return 404 and the toast cleanly degrades to elapsed-time-only).

- **No visual indicator while a slice job was running — users couldn't tell if a long slice was still progressing or had hung** — Previously SliceJobTrackerProvider emitted one transient toast on enqueue ("Slicing X in the background…") and one on completion ("Sliced X"), with nothing in between. For large multi-color models that take 30s–several minutes to slice, the start toast auto-dismissed after 3s and left a UX dead zone where users would ask "is it still slicing?". The tracker now opens a persistent `slice-job-{id}` toast with a spinner that updates every second showing elapsed time + phase ("Queued: X — 4s" → "Slicing X — 47s"), then is replaced by the existing transient success/error toast on terminal state. Polling cadence (1.5s) is unchanged — a separate 1Hz tick re-renders just the elapsed-time counter so the toast stays smooth even if the backend is slow to respond. Time format compresses gracefully past 60s ("1m 5s") and 60m ("1h 12m"). 4 new unit tests in `SliceJobTrackerContext.test.tsx` covering: persistent toast renders at t=0 (no wait for first tick), elapsed time updates each second while running, success completion replaces persistent with transient "Sliced X", failure replaces with transient error toast carrying the sidecar's `error_detail`. New i18n keys `slice.queuedToast` / `slice.runningToast` localised across all 8 UI languages (English + German fully translated, the six others seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features).

- **MakerWorld URL-paste resolver listed plate instances without showing which printer each was sliced for** — MakerWorld's `/instances/hits` endpoint omits the per-instance compatibility info that lives on `design.instances[].extention.modelInfo` (`compatibility` = primary printer the instance was sliced for, `otherCompatibility` = additional printers the uploader marked it compatible with), so every instance row in the resolved-design preview looked identical and users blindly picked the first one regardless of whether it matched their printer — leading to "I downloaded the H2D version and got A1 g-code" complaints. The resolve route now joins both endpoint payloads by instance ID and forwards both fields onto each hit; the MakerWorld page renders "Sliced for {primaryPrinter}" + (when present) "Also marked compatible: ..." per instance row. Backend tests in `test_makerworld_routes.py::TestResolve` cover the merge happy path (compatibility lists land on the right hits) and the "missing modelInfo" fallback (older designs / hits without a matching design.instances entry don't crash the response, just lose the optional fields). New i18n keys `makerworld.slicedFor` / `makerworld.alsoCompatible` localised across all 8 UI languages.

- **Moving a file to an external folder updated the DB row but never wrote the bytes to the mount** ([#1112](https://github.com/maziggy/bambuddy/issues/1112) follow-up — confirmed by @Carter3DP after testing 0.2.4b1) — Carter's report read "the file appears in Bambuddy but not physically on the external folder", which traced to `move_files` only updating `file.folder_id` in the DB while leaving the bytes in the internal `library_files_dir`. Direct upload to a writable external folder was already fixed in 0.2.4b1; the move path was not. Cross-boundary moves now physically relocate the bytes through a new `_move_file_bytes` helper. Same-boundary moves (managed → managed) keep the existing DB-only fast path because the file's on-disk location doesn't depend on which managed folder owns it. The helper handles four flows: managed → external (copy bytes to `<external_path>/<filename>`, flip `is_external=True`, store the absolute path, unlink the managed source), external → managed (copy bytes into internal storage with a fresh UUID name, flip `is_external=False`, store the relative path, unlink the external source, recompute `file_hash` since scan-tracked rows historically carry `file_hash=None`), external → external (same as managed → external), and managed → managed (DB-only). Copy-then-unlink ordering means a partial copy followed by a failed unlink leaves both copies on disk rather than losing the source if the target write fails halfway through on a flaky NAS mount. Failed `shutil.copy2` cleans up partial dest before raising. Defence-in-depth checks block: source on a read-only external mount (move = delete-on-source which a RO mount can't fulfil — would copy-then-fail-to-unlink and silently duplicate the file), filename collisions on the target mount (won't silently overwrite a file the user already has on the NAS), traversal-style filenames after `Path.resolve()`, missing source on disk, and `os.access(W_OK)` on the target mount. Each skip carries a structured `{file_id, code, reason}` entry in a new `skipped_reasons` field on the response so the UI can surface "5 of 10 files skipped: 3 had filename collisions on the NAS, 2 are no longer on disk" instead of a blank "skipped: 5". The original `{moved, skipped}` numeric counters are preserved so existing frontend code that only reads those keeps working unchanged. Six new integration tests in `test_external_folders_api.py::TestCrossBoundaryMove` covering: managed → external relocates bytes (the actual #1112 fix — bytes land on mount, internal source removed, DB row matches reality), external → managed relocates bytes (symmetric path including hash recompute), name collision on target external mount skips with `code: "name_collision"` and leaves the pre-existing target file intact, source on read-only external mount skips with `code: "source_readonly"`, managed → managed stays DB-only (file_path doesn't change, no shutil.copy), and `skipped_reasons` is always present (empty list when nothing skipped) so frontend code can treat it as the source of truth without optional-chaining.

- **`bambuddy.log` filling with `Exception terminating connection ... CancelledError` + `database is locked` cascades on long uploads** ([#1112](https://github.com/maziggy/bambuddy/issues/1112) follow-up, surfaced by @Carter3DP's support package) — Two-part fix to a single root cause: Starlette's `BaseHTTPMiddleware` (which FastAPI's `@app.middleware("http")` decorator uses under the hood) cancels the inner task scope when a client disconnects mid-request — common on long multipart uploads where the client times out before the server's response. Pre-fix `get_db` only caught `Exception`, but `CancelledError` is a `BaseException`, so cancellation skipped the rollback path entirely; the SQLite write lock stayed held until the connection was eventually GC'd, producing the `(sqlite3.OperationalError) database is locked` cascade against `runtime_seconds` updates and other tight-loop writers in @Carter3DP's log. Postgres users would see pool exhaustion / "QueuePool limit overflow" instead of file-level lock contention, but the leak shape is identical. **(1)** `get_db` now catches `BaseException` so `CancelledError` triggers rollback, and wraps both `rollback()` and `close()` in `asyncio.shield` so the cleanup completes even when the await itself is being cancelled by the same cancel scope. The SQLite write lock is released promptly; the connection returns to the pool instead of leaking until GC. **(2)** A `CancelledPoolNoiseFilter` (new `logging_filters.py` filter, attached to `sqlalchemy.pool`) drops the residual log noise that pre-existing pools still emit during their own cleanup — both the `Exception terminating connection ... CancelledError` records (matched on prefix + cancellation-driven `exc_info`, including chained `__cause__`/`__context__`) and the symptomatic `garbage collector is trying to clean up non-checked-in connection` records. Real pool problems — broken connections, network hiccups, exhaustion — keep flowing because they carry a different exception chain or a different message prefix; verified by `test_keeps_terminate_with_real_oserror` and `test_keeps_unrelated_pool_message`. 13 new regression tests across `test_get_db_cancel_safety.py` (commit on clean exit, rollback on regular `Exception`, rollback on `CancelledError` — the actual #1112 fix, close runs even if rollback raises, close failure on clean exit doesn't propagate, both rollback + close go through `asyncio.shield`) and `test_cancelled_pool_filter.py` (drops cancellation-driven terminate, drops GC-cleanup, keeps real `OSError` terminate, keeps terminate without `exc_info`, keeps unrelated pool messages, drops chained-cause `CancelledError`, defensive guard against self-referential cause chains). Applies to SQLite and PostgreSQL — `get_db` is dialect-agnostic and the filtered messages come from base `sqlalchemy.pool` not from any specific dialect.

- **Windows install: `bambuddy.log` filling with `WinError 10054 — _ProactorBasePipeTransport._call_connection_lost` tracebacks** ([#1113](https://github.com/maziggy/bambuddy/issues/1113), reported by @cadtoolbox) — Cosmetic-but-noisy. When a printer / MQTT broker / camera RSTs a TCP socket instead of FINing it (offline X1Es in @cadtoolbox's setup, network gear that drops idle TCP, the printer firmware's own watchdog), Windows asyncio's Proactor cleanup path tries `socket.shutdown(SHUT_RDWR)` on the already-dead socket and hits `WinError 10054`. Application-layer reconnect logic (paho-mqtt, httpx) handles the actual disconnect fine — paho retries, MQTT comes back, telemetry resumes — so the traceback is pure asyncio bookkeeping noise, but it fired multiple times per minute on @cadtoolbox's 9-printer setup with 5 offline X1Es and was the first thing in the sanitized log. Adds a custom `loop.set_exception_handler` (new `backend/app/core/asyncio_handlers.py`) installed on Windows only that pattern-matches the specific `_call_connection_lost` cleanup-RST signature (three signals together: `sys.platform == "win32"`, the exception is `ConnectionResetError`, and the asyncio message string contains `_call_connection_lost`) and downgrades it to DEBUG. Real `ConnectionResetError`s raised inside application coroutines (different message string) and other Proactor cleanup errors (`BrokenPipeError`, `ConnectionAbortedError` — same callback site, distinct signal worth keeping visible) all pass through to `loop.default_exception_handler` unchanged. Linux / macOS use the Selector event loop and never hit this codepath, so `install_proactor_reset_filter()` is an explicit no-op there with a `False` return — verified by `test_install_is_no_op_on_non_windows`. 9 unit tests in `test_asyncio_handlers.py` cover: discriminator matches the exact reported signature, rejects unrelated `ConnectionResetError`s, rejects `BrokenPipeError` even on the same callback site, rejects when no exception object is present, install is platform-gated, install wires the handler onto the loop, suppression doesn't reach the default handler, and unrelated exceptions still hit the default handler. Wired from `lifespan` startup before any task can spawn that might trip it.

- **Auto-Print G-code Injection: start snippet landed before printer startup, and `{placeholder}` substitution was silently broken** ([#422](https://github.com/maziggy/bambuddy/issues/422) follow-up) — Two compounding bugs surfaced by @pleite (Swapmod) and @DevScarabyte (multi-height test prints) on the initial #422 ship: **(1)** Start snippets were prepended to the entire `plate_X.gcode` content, which placed them *before* the printer's bed-heat / homing / nozzle-prime sequence — so a Swapmod start snippet that assumed nozzle-at-temp ran on a cold printer. The injection now anchors at `; MACHINE_START_GCODE_END` (the marker sitting at the bottom of every Bambu/Orca slicer's `MACHINE_START_GCODE` block, after `M109` wait-for-temp), matching where a slicer-side custom-start-gcode would land. Files without the marker (older slicer versions) keep the prepend behaviour as a fallback with a warning log. **(2)** Slicer-style placeholders like `G1 Z{max_layer_z} F600` were written verbatim to the output gcode — the printer firmware then parsed `Z{max_layer_z}` as `Z1` and crashed the head into the print on a 60mm-tall model (a real safety issue: prints damaged, top glass + AMS pushed up off the printer when the model was taller than the hard-coded park height). Added a header parser that reads the 3MF's `; HEADER_BLOCK_START..END` block (lowercased keys, `[units]` suffix stripped, spaces → underscores) and a Prusa-style `{name}` substitution pass that runs over both start and end snippets before injection. Supported placeholders: `{max_layer_z}` / `{max_print_height}` (top-layer Z), `{total_layer_number}` / `{total_layers}`, `{total_filament_weight}`, `{total_filament_length}`, plus any other normalised header key from the source file. Unknown placeholders are left in the snippet verbatim with a warning log — a typo never silently expands to an empty string and the firmware never receives a malformed `Z` parameter. 16 new regression tests in `test_gcode_injection.py` covering: start snippet anchored to the marker (printer startup runs first, snippet sits between `M109 S220` and the marker, file head untouched), missing-marker fallback path, end snippet still appended at EOF, `{max_layer_z}` resolved through the alias map, direct-key substitution from the normalised header, unknown-placeholder pass-through, and direct unit tests for each new helper (`_parse_3mf_gcode_header`, `_substitute_placeholders`, `_inject_start_at_marker`). Wiki page documents the supported placeholder list with a safety warning specifically calling out `{max_layer_z}` for park moves.

- **Camera page ignored `?fps=N` URL parameter** ([#1131](https://github.com/maziggy/bambuddy/issues/1131) diagnostic) — `CameraPage.tsx` hard-coded `fps=15` in the stream URL and never read the URL query string, so `/camera/1?fps=5` (and similar diagnostic suggestions for the freeze report) were silent no-ops. The sibling `StreamOverlayPage` already honoured `?fps=` correctly; the bug was that `CameraPage` was the gap. Now reads `searchParams.get('fps')` via `useSearchParams`, parses it, falls back to 15 on missing/non-numeric, clamps to the backend's 1–30 range, and threads the resulting value into the stream URL. Backend `generate_rtsp_mjpeg_stream` already accepted the parameter and re-clamps per-model (chamber-image A1/P1 capped at 5, RTSP capped at 30). 5 new regression tests in `CameraPage.test.tsx::fps URL parameter (#1131)` cover default-15, honoured value, clamp-above-30, clamp-below-1, and non-numeric fallback — same matrix `StreamOverlayPage.test.tsx` already pins. Independent of the underlying freeze investigation in #1131; surfaced while triaging that report.
- **Reprint-from-archive failed with `0500_4003` SD R/W errors after a stuck dispatch, fixable only by restarting the container** ([#1136](https://github.com/maziggy/bambuddy/issues/1136)) — Reported by @smandon: reprinting from archives sometimes fails immediately with MicroSD R/W exception errors, with the printer's MQTT push referencing a 3MF file from a *different unrelated* archive (`WARIO_Wall_decor_-_NO_AMS.3mf` while the user was actually trying to print `Cable_Organiser_Cable_Clip.3mf`). Once it starts happening, every subsequent reprint hits the same error until the container is restarted. Root cause traced from his support package log to paho-mqtt's client-side QoS 1 queue: when the printer's command channel goes half-broken (telemetry still flowing, publishes silently dropped — same #887/#936 pattern), Bambuddy's 15s dispatch deadline expires (`background_dispatch.py:993`) and calls `force_reconnect_stale_session()`. That function was force-closing the underlying socket so paho's auto-reconnect would kick in — but the same `mqtt.Client` instance, same `client_id`, and **same in-process QoS 1 queue** stayed alive across the reconnect. Any unacked publish from the broken session — typically the just-sent `project_file` for the new archive — got replayed verbatim on the new connection. And because the in-process queue accumulates across multiple stuck dispatches within one Python process, by the second or third stuck reprint there were several stale `project_file`/`resume`/`stop`/`clean_print_error` commands queued up and replaying together. The printer received the flood, tried to load whichever stale path the firmware latched onto last, found a file that no longer existed on its SD card → `0500_4003`. Container restart was the only thing that fixed it because it was the only thing that wiped paho's in-process queue. Replaced the socket-close with a context-aware reconnect: `force_reconnect_stale_session()` and `check_staleness()` now go through a routing helper `_reset_client_for_reconnect()` that picks the right teardown strategy based on caller context. **Async-context callers** (the dispatch deadline path — `background_dispatch.py:993` — which is the actual #1136 trigger, plus FastAPI route handlers via `check_staleness`) get the **hard-reset path**: `client.disconnect()` (broker sees DISCONNECT and drops the session immediately, since `clean_session=True`), `client.loop_stop()` (kills the paho network thread, taking its QoS 1 queue with it), nulls out `self._client`, and calls `self.connect()` to construct a fresh `mqtt.Client` with an incremented `client_id`. New connection starts genuinely empty, no replay possible. **Paho-network-thread callers** (the developer-mode probe and `ams_filament_setting` zombie detection inside `_update_state`, lines ~2604 and ~2623) keep the **socket-close fallback** — calling `loop_stop()` from inside the network thread would self-join and deadlock, so the safe pattern there remains "close the socket and let paho's own loop detect it and auto-reconnect on the same client". Theoretical queue replay is still possible on those paths but #1136 specifically traced through the dispatch path, and the legacy socket-close has been battle-tested for the zombie paths since #887. Routing decision is made via `asyncio.get_running_loop()` — paho's callback thread has no loop, every legitimate hard-reset caller does. 7 regression tests across two new test classes: `TestForceReconnectRouting` (3 tests pinning the sync-context → socket-close fallback, async-context → hard-reset path with mock-stubbed `connect()`, and the state-disconnected broadcast firing once on either path) and `TestHardResetClientDirect` (3 tests pinning the helper directly: old client receives `disconnect()` + `loop_stop()`, `_client` reference cleared, failing `disconnect()` doesn't propagate so the await chain in `background_dispatch.py` doesn't break). Existing `TestZombieSessionDetection::test_two_timeouts_force_reconnect` and `TestDeveloperModeProbeTimeout::test_second_timeout_forces_reconnect` updated to assert the socket-close path (matching their paho-thread context), preserving the legacy contract. All 2179 backend unit tests pass. Thanks to @smandon for the precise reproduction logs that made this diagnosable from a single support package.

- **`logs/bambuddy.log` was silently dropping records from named child loggers** — When the trace-ID column was added to the log format (`%(trace_id)s`), the `TraceIDFilter` was attached to the root logger. Per Python's logging semantics, a filter on a `Logger` only fires for records that *originate* at that logger — records propagated up from child loggers (every `backend.app.*` module — most of the application) never trigger it. Result: child-logger records arrived at the file handler with no `trace_id` attribute, the formatter raised `KeyError: 'trace_id'`, and `Handler.handleError` printed to stderr and dropped the record. `bambuddy.log` ended up with INFO/DEBUG records appearing only "partially" — exactly the records emitted directly through `logging.info(...)` (root logger) or `uvicorn.access` (which had its own explicit filter attachment) made it; everything else was discarded. Moved `_trace_id_filter` from `root_logger.addFilter()` to `console_handler.addFilter()` + `file_handler.addFilter()` — handler-level filters fire for every record the handler receives, regardless of which logger emitted it. The filter's own docstring already said "Attach to the file handler (or any handler whose format string references `%(trace_id)s`)" — the implementation was just wrong. New regression test in `test_trace.py::TestFilterMustBeAttachedToHandlerNotLogger` pins the contract: a child logger emits a record, propagation reaches the handler-level filter, the formatter sees a populated `trace_id` field, and the line is written. Existing 23 trace tests keep passing unchanged. Restart-shutdown recursion in journalctl was also a side effect — every shutdown log line was raising the formatter `ValueError`, which got caught and logged… raising again, forever, until the lifespan exit unwound; the new placement breaks the cycle since records now format cleanly.

- **User-cancelled prints surfaced as "1 problem" on the printer card AND were archived as "Layer shift" failures** — Cancelling a print left the printer card stuck on a permanent "1 problem" badge, *and* stamped the resulting archive entry with `failure_reason="Layer shift"` — a fake firmware-fault label in the print history. Affects every Bambu printer that emits a cancel-sequence HMS — the user surfaced it on an H2D where the firmware emits both `0300_400C` ("The task was canceled.") and the not-in-the-public-wiki `0C00_001B` echo as part of the cancel sequence. Four compounding causes, all fixed together. **(1) The direct stop endpoint never set the user-stopped flag.** `POST /printers/{id}/print/stop` (`backend/app/api/routes/printers.py`) sent the MQTT stop command but didn't call `mark_printer_stopped_by_user()`, so when the printer reported "failed" via MQTT the on_print_complete override (`main.py:2558`) couldn't reclassify it as "cancelled". The same flag *was* being set from `POST /print-queue/{id}/stop`, which is why queue-driven cancels mostly worked but printer-card cancels didn't. The direct endpoint now mirrors the queue path. **(2) The HMS → failure_reason heuristic was way too broad.** Old code mapped *any module 0x0C HMS* to "Layer shift" (`main.py:3072`), but module 0x0C is "Motion Controller" — covers cameras, visual markers, the BirdsEye assembly *and* the cancel-sequence HMS the firmware emits during a user-cancel. Real layer-shift codes actually live in module 0x03 (`0300_4057`, `0300_4068`, `0300_800C`). The same module-only heuristic was also being used to auto-label "Filament runout" (any 0x07) and "Clogged nozzle" (any 0x05), so the same false-positive class existed on those branches. Replaced the broad module heuristic with a curated short-code → reason map (`_HMS_FAILURE_REASONS`, 23 specific HMS codes from the real wiki); anything not in that map leaves `failure_reason=None` rather than guessing. Also extracted the logic into a pure function `derive_failure_reason(status, hms_errors)` so it's unit-testable without the full archive pipeline. **(3) Cancel-echo HMS codes were polluting `state.hms_errors`.** Even with (1) and (2) fixed, the printer card kept showing "1 problem" because the firmware kept reporting `0300_400C` ("The task was canceled.") in subsequent MQTT pushes — and `bambu_mqtt._update_state` was happily appending it to `state.hms_errors`, where the frontend's `filterKnownHMSErrors` accepted it as a valid known code (it IS in `ERROR_DESCRIPTIONS` — just describing a user action, not a fault). Added a parse-time filter (`_HMS_USER_ACTION_CODES = {"0300_400C", "0500_400E"}`) that drops these short codes before they ever enter the state, mirroring the suppression `main.py:_HMS_NOTIFICATION_SUPPRESS` was already doing for notifications. The card pip, the "X problem" badge, the modal, and any other consumer of `hms_errors` all get consistent behavior automatically. **(4) Frontend counted `gcode_state="FAILED"` without HMS as a problem.** Even with (1)–(3) fixed, the printer card *still* showed "1 problem" because the H2D's `gcode_state` sits at `FAILED` after a cancel until the next print starts, and `PrintersPage.tsx:940` (header badge) + `classifyPrinterStatus` (line 1028) + `BulkPrinterToolbar.tsx:102` all unconditionally bumped the `error` bucket on `case 'FAILED'`. Real failures attach an HMS error; user-cancels don't — so FAILED-without-HMS now buckets as `finished` (same operator meaning: print ended, plate may need clearing) and only escalates to `error` when there's an active known HMS. Same change applied across all three call sites for consistency. 20 regression tests total across three files: `test_failure_reason_derivation.py` (11 tests pinning the cancel-sequence HMS pair to NOT yield "Layer shift", unknown module-0x0C → None, real layer-shift/runout/clog codes still classify, int-vs-hex code-format tolerance, `status="cancelled"` symmetric with `"aborted"`), `test_bambu_mqtt.py::TestHMSUserActionFiltering` (4 tests pinning `0300_400C`/`0500_400E` filtering on both `hms[]` and `print_error` parse paths, real layer-shift `0300_4057` still passes through, mid-cancel concurrent real-fault keeps the real one and drops only the echo), and `PrintersPageBucketing.test.ts` (5 tests pinning FAILED-without-HMS → finished, FAILED-with-known-HMS → error, FAILED-with-only-unknown-HMS → finished, FINISH baseline unchanged, disconnected stays offline). Existing stale state on running printers clears on the next MQTT push that includes an `hms` key (printer firmware re-sends the list, parser filters it out, badge clears). Users with a stuck badge can also click the HMS modal "Clear" button to clear immediately via MQTT command.

- **Settings → API Keys: deleted key stayed on screen until manual reload** — the delete-key mutation marked the `['api-keys']` query stale via `queryClient.invalidateQueries`, which in v5 should also refetch active queries — but in practice the deleted row remained visible until the user reloaded the page. Switched the mutation's `onSuccess` to `queryClient.setQueryData` so the deleted key is filtered out of the cache synchronously the moment the API confirms; no refetch round-trip required, no chance for an invalidation→refetch race to leave the UI stale. Create-path keeps `invalidateQueries` since that one was working correctly. New `SettingsPage.test.tsx` test "removes a deleted key from the list without a page reload" pins the synchronous-removal contract.

- **SpoolBuddy AMS page: re-assigning a just-unassigned spool sometimes showed an empty picker** ([#1133](https://github.com/maziggy/bambuddy/issues/1133) follow-up) — Reported live during the rollout of the #1133 picker change: unassigning a Bambu PLA Metal spool from SpoolBuddy and re-opening the picker showed "no spools available" — the just-freed spool was missing. The investigation surfaced four distinct causes that all needed addressing for the picker to stay correct, plus a deployment-side cause that prevented any of the fixes from reaching the live kiosk. **(1) Dual cache-key shapes for spool assignments**: `SpoolBuddyAmsPage` keys by `['spool-assignments', selectedPrinterId]` while the shared `AssignSpoolModal` keys by `['spool-assignments']`, and `SpoolBuddyAmsPage.unassignMutation.onSuccess` only invalidated the printerId-keyed one, leaving the modal's unkeyed cache stale. Both invalidate calls (mutation success + modal-close handler) now hit both keys; collapsing the two key shapes into one is intentionally deferred since the dual-key pattern predates this change and shows up in 6 components. **(2) Toggle wasn't a real escape hatch**: the existing "Show all spools" toggle's label *said* it would help when a spool was hidden but only bypassed the material/profile filter, not the assignment-elsewhere gate. It now bypasses BOTH filters, making it a real escape hatch (the backend's `assign_spool` is upsert-per-(printer/ams/tray), so picking a currently-taken spool just creates a second assignment row — foot-gun for normal flows but exactly the recovery path this toggle is for). **(3) Cross-component cache pollution**: `['inventory-spools']` was used as a query key by 5+ components calling `getSpools()` with different `includeArchived` arguments — React Query treated them as one query and served whichever response landed first, so a SpoolBuddy component priming the cache with `getSpools(false)` could hide spools from the modal that wasn't yet present at that fetch time. The modal now uses its own dedicated key `['inventory-spools', 'assign-modal']` + `getSpools(true)` so it's never at the mercy of someone else's cache state. **(4) Empty-state had no diagnostic surface**: when the picker showed "No spools available" there was no way to tell *why* — was the fetch empty? Were spools archived? All assigned elsewhere? A small counter `X fetched · Y archived · Z assigned to other slots` now renders in the empty state so future reports of this kind are immediately answerable from a screenshot rather than requiring devtools digging. **(5) Browser holding stale JS forever**: `index.html` was being served without `Cache-Control` headers, so Chromium's heuristic-cache freshness window kept the OLD HTML "fresh" for days across browser restarts. The OLD HTML referenced an OLD content-hashed bundle, which was also still in disk cache, so the kiosk kept running pre-deploy JS *no matter how many times* its Chromium was restarted or cache-cleared — the persistent profile would re-seed the cache from disk on next start. Backend now sends `Cache-Control: no-cache, must-revalidate` on both `/` and the SPA catch-all that serve `index.html`; service worker `CACHE_NAME` bumped from `bambuddy-v25` to `bambuddy-v26` so any client that does eventually re-fetch `sw.js` invalidates its CacheStorage; and `spoolbuddy/install/install.sh` now generates the kiosk launcher with `--user-data-dir=/tmp/spoolbuddy-kiosk-userdata` plus a pre-launch `rm -rf` so every kiosk restart starts from a clean slate (the kiosk has no per-user state worth persisting — auth token is in the URL query, not a stored cookie). 6 net-new tests across `AssignSpoolModal.test.tsx` (toggle escape-hatch behavior) and `tests/integration/test_static_html_cache_headers.py` (Cache-Control directive on root + SPA catch-all routes, no leak onto API routes). Reproduced end-to-end on an H2D + dual AMS + SpoolBuddy display: unassign Bambu PLA Metal Iridium Gold Metallic from slot B4 → reopen picker → spool now visible without browser intervention.

- **Plate-clear button stayed visible after the API cleared `awaiting_plate_clear` outside the printer-card click path** ([#1128](https://github.com/maziggy/bambuddy/issues/1128)) — `awaiting_plate_clear` is a Bambuddy-side flag, not a printer-side one, so toggling it does not produce an MQTT push from the printer. Commit 4e86e8c added the flag to the `printer_status` payload so MQTT-driven broadcasts (e.g. when a print finishes and on_print_complete sets the flag to True alongside a state transition to FINISH) carry it correctly. The reverse transition didn't get the same treatment: `POST /printers/{id}/clear-plate` mutated `PrinterManager._awaiting_plate_clear` and persisted to the DB, but emitted no `printer_status` WebSocket update — and the in-`main.py` status-change broadcaster's `status_key` deduplication intentionally excludes Bambuddy-side flags, so even a coincidentally-arriving MQTT push wouldn't reflect the change. The "Mark plate as cleared" button on the printer card disappeared "immediately" after a click only because the React Query cache was being optimistically updated client-side; clearing the flag through any other route (an admin script, a second tab, an automation hitting the endpoint directly, the scheduler at `print_scheduler.py:1844` when dispatching the next queued print) silently left every UI subscriber but the originating tab stale until a coincidental status refresh. Centralised the broadcast in `PrinterManager.set_awaiting_plate_clear` itself rather than at each call site, so every current AND future caller is covered without remembering to wire it up: a new `_broadcast_status_change(printer_id)` private coroutine is scheduled alongside the existing `_persist_awaiting_plate_clear` whenever the flag flips under a running event loop. The broadcast lazy-imports `ws_manager` to keep `printer_manager.py` clean of application-layer infra at module-import time, short-circuits when `get_status` returns `None` (printer disconnected — the next reconnect produces a fresh push anyway), and swallows `ws_manager.send_printer_status` failures so the persistence path can complete even if the WS layer is temporarily unavailable. The same hook is now in place for any other Bambuddy-side flag that gets added to `printer_state_to_dict` later — they'll all need to broadcast their own changes for the same reason. 8 new regression tests in `test_printer_manager_status_broadcast.py`: schedules-on-True/False/loop-running/no-loop/loop-stopped contracts, `_broadcast_status_change` happy path with payload assertion, skip-when-no-state, swallow-WS-errors, and an end-to-end live-loop test that fires `set_awaiting_plate_clear(False)` and asserts a broadcast lands with `awaiting_plate_clear: false` in the payload. Existing 24 tests in `test_scheduler_clear_plate.py` continue to pass unchanged because they instantiate `PrinterManager()` without attaching a loop (sync unit-test path) — the new `_schedule_async` call short-circuits on the same loop check the existing persistence call already used. Thanks to @EdwardChamberlain for the precise root-cause analysis (down to the exact line and the suggested `ws_manager.send_printer_status()` fix).

- **Uvicorn HTTP access log was missing from `bambuddy.log`, leaving rogue server-state changes untraceable** — When an HTTP endpoint that mutates server state fires unexpectedly (the canonical example: a print spontaneously stopping mid-job because something hit `POST /printers/{id}/print/stop`), the only on-disk trail was Bambuddy's own application log — which by design only records the *outbound* MQTT publish (`Sent stop print command`), not the *inbound* HTTP call that triggered it. The result was an unsolvable mystery on 2026-04-26: prints stopping with no preceding Bambuddy-side log line, no way to identify the caller, and the rotated container stdout already gone by the time the support pack was generated. Root cause: uvicorn ships its `access` logger with `propagate=False` by default, so the existing `RotatingFileHandler` attached to root never received those records. `main.py` now attaches the same file handler directly to `logging.getLogger("uvicorn.access")` and applies a new `WriteRequestsOnlyFilter` (`backend/app/core/logging_filters.py`) that keeps `POST` / `PUT` / `PATCH` / `DELETE` and drops `GET` / `HEAD` / `OPTIONS`. Status polls, camera streams, snapshot fetches, websocket upgrades, and CORS preflights account for the bulk of access traffic on a running install and none of them can change server state on their own — dropping them keeps `bambuddy.log` focused on lines that matter for incident triage without churning the 5 MB rotation window faster than it's useful. Filter anchors on the `" `+verb+` ` pattern uvicorn's format string guarantees, so a literal `"POST"` substring inside a URL (e.g. `GET /api/posts/POST_123`) cannot false-match. The filter lives in its own module so the test suite can import it without pulling in `main.py`'s entire startup graph. 13 new tests in `test_logging_filters.py` cover all four write verbs being kept, GET/HEAD/OPTIONS being dropped, two URL-contains-verb-substring false-match guards, empty/unrelated-line/idempotency edge cases. Output now looks like `2026-04-26 09:23:14,690 INFO [uvicorn.access] 192.168.1.42:54812 - "POST /api/v1/printers/1/print/stop HTTP/1.1" 200` — one `grep "POST.*stop"` away from "who triggered this".

- **Spool auto-assign hit `IntegrityError` on Postgres when AMS pushes arrived in quick succession** — Bambu MQTT can deliver two `ams_data` push frames for the same printer ~30 ms apart (observed on H2D + dual AMS at K-profile-load / RFID-read boundaries). Each frame triggers `on_ams_change` in `backend/app/main.py`, whose auto-assign block reads `(printer_id, ams_id, tray_id)`, decides "no existing assignment", and INSERTs via `auto_assign_spool` — and the two callbacks raced in their respective sessions, both deciding to insert, with the second commit losing on `spool_assignment_printer_id_ams_id_tray_id_key`. SQLite's WAL serial-write semantics had been silently swallowing the race for ~7 weeks since the spool-assignment feature shipped (latent in `ec82092b`); when optional Postgres support landed in `610431d6` and asyncpg started allowing true concurrent transactions, it surfaced as `WARNING [main] RFID spool auto-assign failed: ... duplicate key value violates unique constraint ...; DETAIL: Key (printer_id, ams_id, tray_id)=(1, 0, 0) already exists`. Added a per-printer `asyncio.Lock` (`_ams_assignment_locks` keyed by `printer_id`) wrapping the auto-assign critical section so two callbacks for the same printer serialise — by the time the second one's session runs `select(SpoolAssignment).where(...)`, the first's commit is visible and the early-return "existing assignment" branch fires instead of a duplicate INSERT. The Spoolman sync block further down in the same callback intentionally stays OUTSIDE the lock — it's network-bound and idempotent, so serialising it would block subsequent AMS callbacks for the duration of a remote roundtrip. Per-printer scope keeps unrelated printers fully parallel: one printer's slow assignment never blocks another's. The auto-unlink block above the assign block isn't wrapped because its DELETE/UPDATE operations don't have the same constraint surface; the assign-block lock is sufficient because the second callback's `select` will see the first's committed state. 5 new regression tests in `test_ams_assignment_lock.py` cover same-printer-same-lock identity, different-printers-different-lock isolation, second acquirer waits for first inside the lock (proves serialisation), different printers run truly in parallel under a held lock (proves per-printer scope), and an auto-cleanup fixture resets the module-level dict between tests so cross-test loop affinity bugs can't surface.

- **Camera TLS proxy logged "Unhandled exception in client_connected_cb" when ffmpeg dropped its half of the connection mid-stream under uvloop** — The bidirectional forwarders inside `services/camera.py::create_tls_proxy._handle` (the OpenSSL TLS shim added in [#661](https://github.com/maziggy/bambuddy/pull/661) so Bambu's RTSPS handshake works around Debian GnuTLS hardening) caught `(ConnectionError, OSError, asyncio.CancelledError)` on writes, but uvloop's `UVStream.write` raises a plain `RuntimeError` from `UVHandle._ensure_alive` when the underlying handle is already closed. asyncio's default selector loop reports the same situation as `ConnectionResetError`, so the bug only surfaced on uvloop deployments — and only at the moment the client (typically ffmpeg or a snapshot-capture subprocess) tore down its socket while the proxy was mid-flush. The `RuntimeError` slipped past the except tuple, escaped the forwarder coroutine, and asyncio's `client_connected_cb` task-exception handler logged a noisy multi-line traceback ending in `RuntimeError: unable to perform operation on <TCPTransport closed=True ...>; the handler is closed`. Added `RuntimeError` to the except tuple in both `_fwd_to_server` and `_fwd_to_client` (the latter being the actual frame in the bug report — server→client is where buffered TLS chunks land after the client has gone). The forwarders are intentionally fire-and-forget on tear-down; once either peer drops, both halves of the proxy should exit quietly and the existing `dst.close()` in the `finally` block already handles cleanup. No functional regression possible — the connection is already dead by the time the exception fires; this only changes whether asyncio logs an "Unhandled exception" trace for it. 2 new regression contract tests in `test_camera_tls_proxy.py` use `inspect.getsource` to assert both forwarder closures' except clauses include `RuntimeError`, since the closures are nested inside `_handle` and extracting them just for testability would require a pure-cosmetic refactor of the proxy.

- **Background-dispatch reported "Print started successfully" when the printer never actually transitioned** ([#1134](https://github.com/maziggy/bambuddy/issues/1134), follow-up to [#1042](https://github.com/maziggy/bambuddy/issues/1042)) — The int32 `task_id` modulo fix that was the original root cause of #1042 is verified working in the reporter's most recent support pack (the published `task_id` values are well below 2^31-1 and match the `int(time.time() * 1000) % 2_147_483_647` formula exactly). The remaining residual — "the UI reports despatch success which is slightly misleading" — was a real second bug class: the post-dispatch watchdog `_verify_print_response` in `services/background_dispatch.py` was *fire-and-forget*. It would correctly detect that the printer never transitioned (e.g. P1S sitting in `gcode_state: FAILED` with HMS `0300_400C` "task was canceled", a half-broken MQTT session, an SD card error, or any other pre-print blocker), log a `did not respond to print command within 15s` warning, force-reconnect the MQTT session — and then return without touching the dispatch job state. The dispatch job had already been marked successful on the optimistic MQTT-publish-acknowledged path, so the UI carried on showing "Print started successfully" while the printer sat idle. The watchdog now returns a `bool` and is awaited inline by both call sites (`_run_reprint_archive` at line 687, `_run_print_library_file` at line 860); on `False` (timeout) the call sites raise a `RuntimeError` carrying a user-actionable message ("Printer did not acknowledge print command — state still {pre_state}. Check the printer for a pending error (HMS code, plate-clear prompt, SD card) and try again."), which routes through the existing `_mark_job_finished(failed=True, …)` path so the dispatch UI shows a real failure toast and the library-file flow's freshly-created archive is `db.rollback()`'d (no orphan rows for prints that never started). The watchdog now also accepts `subtask_id` advancing past the captured `pre_subtask_id` as a definitive "command landed" signal — same as the queue-side watchdog at `print_scheduler.py:1992` (#1078) — so slow H2D `FINISH→PREPARE` transitions (~50 s observed) don't false-fail when the printer has clearly accepted the project_file but is still in FINISH. Default timeout raised from 15 s to 90 s to match the queue-side watchdog (#967 / #1078) and give the same headroom on both dispatch paths. Brief mid-window MQTT disconnects (`get_status() is None` for one tick) now keep polling instead of immediately failing — matches what the queue watchdog already does and avoids false-failing on transient telemetry gaps. The existing `force_reconnect_stale_session` recovery is preserved on the timeout path. 8 new regression tests in `test_background_dispatch_watchdog.py` cover state-change pickup, subtask_id-change pickup with state still FINISH (the H2D case), neither-signal-changed timeout + force-reconnect, pre_subtask_id=None backwards-compat, post-dispatch subtask_id=None not counting as a change (avoids false-pass on transient reconnect), brief disconnect not short-circuiting the window, persistent disconnect for the full window returning False, and a contract test that the default timeout is 90 s. Thanks to @EdwardChamberlain for the detailed retest with logs that pinpointed the watchdog's no-propagation gap.

- **Bambu RFID auto-match created duplicate inventory rows for Quick-Add and non-Bambu-branded spools** ([#918](https://github.com/maziggy/bambuddy/issues/918)) — `find_matching_untagged_spool` is supposed to attach a Bambu RFID UID to a pre-existing manually-logged spool of the same material/color so users who log inventory before scanning don't end up with a duplicate row on first AMS read. Two bugs in the matcher meant it almost never worked for the actual reporting workflow: **(1)** the subtype filter was strict — when the AMS tray reports `tray_sub_brands="PLA Basic"` the matcher required `Spool.subtype = 'Basic'` exactly, so any Quick-Add row (Quick-Add only requires `material`, leaving `subtype=NULL`) was excluded and duplicated on first AMS read. **(2)** the docstring claimed it filtered on brand but the WHERE clause didn't, so a same-color *Polymaker* untagged spool would silently acquire a Bambu Lab tray UUID, leaving the user with `brand="Polymaker"` but a Bambu UUID — silent data corruption. Both bugs are addressed in the same query: subtype now prefers an exact match but accepts a NULL-subtype row as fallback (with a `CASE` in `ORDER BY` so an exact match still wins when both exist), and brand is now restricted to "contains 'bambu' (case-insensitive)" or NULL — matching `'Bambu'` (the form's `DEFAULT_BRANDS` value), `'Bambu Lab'` (the catalog value), `'BambuLab'`, `'bambu lab'`, etc., while rejecting any explicitly-named third-party brand. 6 new regression tests in `test_spool_tag_matcher.py` cover the NULL-subtype fallback, exact-subtype-wins-over-NULL ordering, non-Bambu brand rejection, NULL brand acceptance, all four Bambu brand spelling variants, and the full Quick-Add scenario (`brand=NULL` + `subtype=NULL`). The broader UI proposals in #918 (manual override / merge / disambiguation prompt) are intentionally out of scope — once the matcher works, the duplicate-on-RFID complaint that motivated those proposals goes away. Thanks to @ViridityCorn for the report and pointing at the right function, and to @Arn0uDz for confirming with a 20-spool repro.
- **Swagger UI link in Settings → API Keys rendered a blank page** — the global CSP applied by `security_headers_middleware` set `script-src 'self'` and `style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`, which blocked both the inline `<script>` that boots Swagger and the `cdn.jsdelivr.net` URL that ships `swagger-ui-bundle.js` / `swagger-ui.css`. FastAPI's `/docs` page therefore loaded a 1 KB shell with no JS executed, leaving an empty white page. The middleware now emits a docs-scoped CSP for `/docs`, `/redoc`, and `/docs/oauth2-redirect` that allows `https://cdn.jsdelivr.net` for scripts + styles, the FastAPI/Redoc favicon hosts for images, and `'unsafe-inline'` for the Swagger boot script — every other route keeps the unchanged stricter SPA policy.
- **Camera stream second viewer fails / kicks the first off** ([#1089](https://github.com/maziggy/bambuddy/issues/1089)) — Most Bambu Lab printers only allow one concurrent camera connection (RTSP socket on X1/H2/P2, port-6000 chamber-image socket on A1/P1), but `GET /printers/{id}/camera/stream` opened a fresh upstream per viewer keyed on a per-request `stream_id`. Two browser tabs / two dashboard cards → the second viewer either failed silently or kicked the first one off. New `services/camera_fanout.py::MjpegBroadcaster` owns a single upstream per printer and fans pre-formatted MJPEG chunks out to N subscriber queues; new viewers tap the existing connection. When the last subscriber leaves, the upstream stays alive for a 5 s grace window so a tab refresh or "open in new tab" doesn't pay an ffmpeg/RTSP reconnect, then tears down cleanly. Per-subscriber queues are bounded (depth 4) so a slow viewer drops frames for itself rather than blocking the broadcaster — live video, old frames have no value. Stop endpoint and app-shutdown both call into the broadcaster's force-shutdown path so subscribers wake up via an upstream-gone sentinel instead of hanging on `queue.get()`. External-camera path is unchanged (user-supplied MJPEG/RTSP servers handle multi-viewer themselves). The upstream uses a deterministic `{printer_id}-fanout` stream id so every existing prefix-match in `cleanup_orphaned_streams`, `camera_status`, the snapshot fall-through in `main.py`, and the `stop` endpoint continues to find it without changes. Two follow-up correctness fixes from the audit pass: (1) `_stream_start_times[printer_id]` is now set with `setdefault()` so `/camera/status` reports the SHARED upstream's age — previously each new viewer overwrote it, making `stream_uptime` jump backward whenever a second viewer attached; (2) the route now retries `subscribe()` once on `RuntimeError` to close a tiny race where the grace teardown can flip the broadcaster to `stopped` between the registry lookup and the subscribe call (the retry forces the registry to mint a fresh broadcaster). Detach log line shows the post-unsubscribe count returned atomically by `unsubscribe()` — no more two viewers leaving simultaneously both reporting `subscribers=0`. Permission gates unchanged: `/camera/stream` still requires the existing token (minted by `POST /camera/stream-token` with `CAMERA_VIEW`); `/camera/stop` still requires `CAMERA_VIEW`; the broadcaster is internal infra with no FastAPI surface. 13 unit tests for the broadcaster (single subscriber, multi-subscriber-shares-one-pump, slow-subscriber-doesn't-block-fast, grace-window teardown, grace-cancelled-on-rejoin, force-shutdown sentinel, `iter_subscriber` exits on upstream-gone and on client-disconnect, registry replaces stopped broadcasters, `subscribe()` raises on stopped broadcaster, `unsubscribe()` returns post-removal count atomically across concurrent leavers, double-unsubscribe is idempotent, and the route's force-shutdown-then-fresh-subscribe retry path) plus 2 new integration tests on the stop endpoint covering the deterministic fan-out stream id and the `shutdown_broadcaster` wiring. Thanks to @swheettaos for the diagnosis and broadcaster sketch.
- **Uploads to writable external folders silently landed in internal storage** ([#1112](https://github.com/maziggy/bambuddy/issues/1112)) — `LibraryFolder` has an `external_readonly` flag, so the model already distinguishes writable from read-only external mounts, but `POST /library/files` rejected only the read-only branch and then unconditionally wrote to `get_library_files_dir()` with a UUID-scoped filename. The resulting `LibraryFile` row linked back to the external folder via `folder_id`, so the file showed up in the Bambuddy UI and could be printed, but the bytes physically lived in `archive/library/files/` and never touched the mount — invisible from any other machine accessing the same NAS/SMB share. New `_resolve_upload_destination()` helper detects writable external targets and writes through to `<external_path>/<filename>` (keeping the original filename so the file is recognisable on the mount), with guards for missing/inaccessible path (400), non-writable mount (400), pre-existing filename on the mount (409 — no silent overwrite; the user is expected to rename and retry, matching how scan treats external files as externally-owned bytes), and a `resolve + relative_to` path-traversal guard on the joined destination. DB row now matches what scan produces: `is_external=True`, `file_path=<absolute external path>`, so the existing download / delete / dedupe paths work unchanged (`to_absolute_path` already fast-paths `is_absolute()` inputs, and external-file deletion already bypasses trash and only drops the DB row + internal thumbnail). `POST /library/files/extract-zip` is now rejected against *any* external folder (not just read-only) with a clear "extract the ZIP on the external mount and run Scan" message — the nested-subfolder creation path would need to `mkdir` on the mount and create matching `is_external=True` `LibraryFolder` rows, which is a separate design round, and the Scan flow already handles that shape. 7 new integration tests cover: bytes land on the mount; DB row has `is_external=True` + absolute `file_path`; filename collision → 409 with prior bytes preserved; vanished external path → 400; path-traversal filename never escapes the external dir; extract-zip into writable external rejected with the Scan hint; root uploads unchanged.
- **Queue item stuck at "printing" when print failed before reaching RUNNING** ([#1111](https://github.com/maziggy/bambuddy/issues/1111)) — Dispatching a file sliced for the wrong nozzle size (or any other pre-print error: AMS fault, wrong plate, nozzle not installed, etc.) left the queue item stuck at `status="printing"` forever, blocking every subsequent pending item for that printer (`check_queue` seeds `busy_printers` from any row in `'printing'` state and skips further dispatches for those printer IDs). Completion detection in `BambuMQTTClient._process_message` required the print to have reached `RUNNING` — either via `_previous_gcode_state == "RUNNING"` or the `_was_running` fallback — but a nozzle-mismatch failure transitions the printer `IDLE → PREPARE → FAILED` without ever entering `RUNNING`, so neither branch matched and `on_print_complete` never fired. The diagnostic log line at `bambu_mqtt.py:2690` ("State is FAILED but completion NOT triggered: prev=PREPARE, was_running=False") confirmed the path. Completion now also fires on `FAILED` from a pre-print state (`PREPARE` or `SLICING`) — restricted to those two so a stale `FAILED` on first connection (prev=None) still can't accidentally advance an unrelated queue item. Additionally, when a queue item transitions to `failed` the handler in `main.py` now populates `error_message` from the printer's current HMS error list, rendered via the existing `backend/app/services/hms_errors.py` lookup table (e.g. `[0500_4038] The nozzle diameter in sliced file is not consistent with the current nozzle setting. This file can't be printed.`) — previously `error_message` was left `NULL`, so users saw "failed" with no hint at the cause. 5 new unit tests in `TestPrePrintFailureCompletion` cover PREPARE→FAILED and SLICING→FAILED firing, IDLE→FAILED and initial-FAILED *not* firing (boot-time safety), and HMS errors being passed through in the callback payload; 6 new tests in `test_hms_error_summary.py` cover the error-message formatter (known-code lookup, unknown-code fallback, multi-error join, malformed-entry tolerance, all-malformed → None, empty → None). Thanks to @MartinNYHC for the report.
- **Tailscale cert-renewal restart silently failed mid-way** (follow-up to [#1070](https://github.com/maziggy/bambuddy/issues/1070)) — The daily renewal path creates an `asyncio.Task` to restart VP services with the new cert. Inside that task, `stop_server()` / `stop_proxy()` call `_cancel_restart_task()`, which cancelled+awaited the currently-running task (itself). The self-await raised `RuntimeError`, got caught by the broad exception handler, but the cancel flag was still set — so the next `await` in `stop_server` raised `CancelledError` and aborted the restart partway through. The VP kept running the OLD expired cert until the process was manually restarted, silently defeating the feature. `_cancel_restart_task` now checks `asyncio.current_task()` and skips the cancel+await when the caller IS the restart task itself. Two new regression tests cover the self-cancel and outside-cancel paths.
- **Settings table filled with duplicate rows on legacy SQLite installs** — pre-UNIQUE-constraint databases stored the `settings.key` column without a unique index, so the seed loop's `INSERT OR IGNORE` silently degraded to a plain INSERT and every `systemctl restart bambuddy` added another row of `advanced_auth_enabled` / `smtp_auth_enabled`. After a handful of restarts, `scalar_one_or_none()` in `is_advanced_auth_enabled` and similar sites blew up with `MultipleResultsFound`, 500'ing the login flow. `run_migrations` now dedupes (keeps MIN(id) per key) and creates the missing `ix_settings_key` unique index before the seed loop runs. Postgres installs were unaffected. 4 new regression tests cover legacy-with-dupes, legacy-already-clean (idempotent), and fresh-install (no-op) paths.
- **Virtual printer card's Tailscale FQDN copy button failed on HTTP** — `navigator.clipboard.writeText` is only available in secure contexts (HTTPS / localhost). When Bambuddy is reached over plain HTTP via a LAN or Tailscale IP, the clipboard API is blocked and the copy button silently failed with a generic "Failed to update settings" toast. Added a legacy `document.execCommand('copy')` fallback via a hidden textarea for non-secure contexts; the textarea is removed in a `finally` block so it doesn't leak into the DOM on exception paths. New `virtualPrinter.toast.copyFailed` i18n key across all 8 locales for the rare case where both paths fail.
- **Install script failed for first-time users** — three separate permission issues in `install/install.sh` stopped the native installer mid-way: (a) `download_bambuddy` chowned the empty install dir to the service user BEFORE running `git clone` as the current user → permission denied on `.git`; (b) `setup_virtualenv` created the venv as the service user but then ran `pip install --upgrade pip` as the current user → permission denied writing `venv/bin/pip`; (c) `build_frontend` would have hit the same pattern on `npm ci`. All three now route through `sudo -u "$SERVICE_USER"` (or `sudo -H -u` for npm so HOME is set correctly for the npm cache). The git-clone fix runs as root then chowns the tree. macOS path unchanged (no service user there).
- **H2C dual-nozzle detection missed post-2026 serial batches** ([#1105](https://github.com/maziggy/bambuddy/issues/1105)) — Bambu has started shipping H2C units with a new serial prefix (`31B8B…` observed on a January 2026 unit) instead of the legacy `094…` shared by the H2D/H2C/H2S family. The K-profile edit flow (`backend/app/api/routes/kprofiles.py`) and the delete-K-profile MQTT path (`backend/app/services/bambu_mqtt.py::delete_kprofile`) branch on serial prefix to pick the dual-nozzle command format, so units with the new prefix were silently falling into the single-nozzle branch and getting the wrong K-profile payload shape. Added `31B8B` (5-char match covering the model code + revision bytes, leaving the revision-letter slot free to iterate) alongside the existing `094` and `20P9` prefixes; runtime paths that auto-detect dual-nozzle from `device.extruder.info` were already prefix-agnostic. New regression test `test_h2c_new_prefix_uses_dual_nozzle_format` in `test_bambu_mqtt.py`. Thanks to @m4rtini2 for the report.
- **Spoolman iframe silently blank on HTTPS Bambuddy with HTTP Spoolman** ([#1096](https://github.com/maziggy/bambuddy/issues/1096)) — Users behind an HTTPS reverse proxy (Traefik / Nginx / Caddy) pointing the Spoolman URL at plain HTTP saw the Filament tab render as a blank page with only a console-side `Mixed Content` warning. CSP was fine (the `#1054` fix already allowed `frame-src http:`), but browsers enforce mixed-content blocking independently of CSP — an HTTP iframe inside an HTTPS parent is always blocked. Bambuddy can't technically fix this (the browser is correct to refuse), so instead of the silent blank frame the Filament page now detects the protocol mismatch (`window.location.protocol === 'https:'` plus Spoolman URL starting with `http://`) and renders an inline warning card explaining the root cause, pointing users at the right fix (put Spoolman behind the same HTTPS reverse proxy and update the Spoolman URL in Settings), and offering an "Open Spoolman in a new tab" button as an immediate workaround — a standalone tab isn't subject to mixed-content rules. Localised across all 8 UI languages. Thanks to @jsapede for the report.
- **Reprint-from-Archive left `created_by_id` as `NULL`** ([#730](https://github.com/maziggy/bambuddy/issues/730) follow-up) — 0.2.4b1 fixed user attribution for Direct Print / File Manager / Library prints, but the reprint path was still unattributed on the *archive* row. Reprint intentionally reuses the source archive (to avoid duplicate rows — see `register_expected_print`), so an archive auto-created from a printer-initiated print with no known user stayed `created_by_id=NULL` forever, even after multiple reprints by authenticated Bambuddy users. Print Log got the reprinter's username correctly (via `_print_user_info`), but the Statistics per-user filter — which reads `archive.created_by_id` — kept showing the archive as unassigned. Fix in `main.py`'s print-complete handler: when the archive has no `created_by_id` and a print-session user is set (which reprint always sets via `set_current_print_user`), back-fill the archive's attribution. Never overwrites an existing attribution — the original uploader keeps ownership; NULL archives are the only ones touched. Thanks to @3823u44238 for the detailed retest that caught this.
- **Settings: failed-save toast looped forever when the user lacked `settings:update`** — the Settings page runs a debounced auto-save effect that fires `PATCH /settings` whenever `localSettings` diverges from the last server snapshot. When a delegated user with `settings:read` but not `settings:update` toggled a control, the effect fired `PATCH`, got `403`, and kept re-firing every ~500 ms producing an endless stream of identical "Failed to save" toasts. Gated at three points so the mutation is never attempted without permission: (1) the `updateSetting` callback — every onChange path — shows one `settings.toast.noPermissionUpdate` toast and short-circuits before diverging `localSettings`; (2) the debounced-save effect safety-nets the same check in case any call site bypassed `updateSetting`; (3) the language `<select>` was a fire-and-forget direct `api.updateSettings` call that always flashed a success toast regardless of outcome — it now goes through `updateMutation` with the same permission guard. New `settings.toast.noPermissionUpdate` key added across all 8 locales with full translations (not English-fallback).
- **Groups: edits to custom-group permissions appeared lost on reopen** ([#1083](https://github.com/maziggy/bambuddy/issues/1083)) — creating a custom group and reopening the editor showed the correct permissions, but after editing that group's permissions and saving, reopening the editor within ~1 minute displayed the pre-edit snapshot as if the save had failed. The backend `PATCH /api/v1/groups/{id}` was persisting correctly (now covered by four new integration tests in `test_groups_api.py`, including a direct DB read after update); the issue was purely in the frontend React Query cache — `GroupEditPage.onSuccess` invalidated `['groups']` (the list) but left the `['group', id]` detail cache stale, and with the app-wide 60 s `staleTime` the next mount served the cached pre-update body instead of refetching. `onSuccess` now primes the `['group', id]` detail cache with the `PATCH` response body so the next mount hits fresh data immediately without a round-trip. Create-path invalidates `['group']` for symmetry. Regression test in `GroupEditPage.test.tsx` verifies the detail cache contains the updated permissions after save.
- **Setup: re-enabling auth could 422 on a password the form no longer needs** — after disabling authentication and re-enabling it (common when switching between local auth and LDAP, or recovering from a bad config), the setup form still sends `admin_password` in the body even though the backend route ignores it when an admin user already exists. The `SetupRequest` Pydantic schema enforced password complexity (uppercase + lowercase + digit + special char) unconditionally, so any existing password that predated the complexity rule — or a legitimate LDAP-mode placeholder — triggered `422 Value error, Password must contain at least one special character` before the route body could decide to ignore the field. Complexity validation has moved out of the schema and into the route body, scoped to the branch that actually creates a new local admin. Re-enabling auth with an existing admin (or any LDAP user) now accepts whatever the form sends; fresh first-time setup still rejects weak passwords with a clear 400. Two regression tests added in `test_auth_api.py`: weak password rejected at setup when creating the first admin, weak/placeholder password accepted when an admin already exists.
- **Queue: batch (quantity>1) double-dispatched onto the same printer** — scheduling an ASAP print with `quantity > 1` could end up with two queue items in `'printing'` status for the same printer, surfaced in the logs as `BUG: Multiple queue items in 'printing' status for printer N`. The scheduler's in-memory `busy_printers` set was seeded empty each tick and only populated after `_start_print` succeeded in the current iteration, so on the next tick (30 s later) `_is_printer_idle()` read the printer's live MQTT state — which on H2D / P1 series lags several seconds behind the print command and still reported `IDLE` / `FINISH` — and dispatched the second batch item onto the already-running printer. `check_queue()` now queries `PrintQueueItem` for `status='printing'` rows and seeds `busy_printers` with their printer IDs before iterating pending items, so any printer with an outstanding dispatched job is excluded regardless of what MQTT currently reports. Regression covered in `test_phantom_print_hardening.py` (`TestBusyPrinterSeedingFromPrintingItems`): seeding query returns printers with `'printing'` rows only, returns empty when none exist, and end-to-end `check_queue()` does not call `_start_print` for a pending item whose printer already has a `'printing'` row even when `_is_printer_idle()` is forced `True`.
- **Queue: active-item progress bar flashed 100% before dropping to 0%** — immediately after a queue item was dispatched, the per-item progress bar on the Queue page showed 100% (or whatever the prior print's final `mc_percent` was) for the few seconds between dispatch and the printer's MQTT state transitioning to `RUNNING`. Frontend `QueuePage.tsx` read `status.progress` directly from the printer's live MQTT snapshot, which carries over the last reported value from the previous print until the new one starts ticking. The progress bar, remaining time, ETA, and layer counter are now gated on `status.state` being `RUNNING` or `PAUSE`; in any other state (including `FINISH` from the prior print, `IDLE`, or `PREPARE` while heating) the bar renders at 0% with no stale ETA/layer values.
- **i18n placeholder mismatches in Japanese rendered literal `{{count}}` / `{{name}}` strings in the UI** — 27 `ja` strings had drifted from the en placeholder names: `printers.activeNozzle` used `{{side}}` while the runtime passes `nozzle`; `archives.card.layers` / `queue.addedBy` / `maintenance.days` / `groups.form.permissions` and 22 others either lost their placeholders entirely (translator dropped them) or used renamed keys (`{{count}}` → `{{username}}`). i18next can't bind a placeholder it doesn't see, so the count would silently disappear or — for `{{count}}` keys — render the raw `{{count}}` token in the UI. Backfilled all 27 to match en's placeholder set so interpolation resolves; the parity check now reports zero placeholder mismatches across all 8 locales. Same pass also fixed one `fr` mismatch (`projects.noProjectsFilteredHelp` lost `{{status}}`).
- **"Open in Slicer" fails on Windows / Linux for any filename containing spaces or special characters** ([#1059](https://github.com/maziggy/bambuddy/issues/1059)) — clicking "Open in Slicer" from the File Manager or Archives page produced one of three symptoms depending on the file: `.3mf` files opened Bambu Studio / OrcaSlicer but the app showed "Importing to Bambu Studio failed. Please download the file and open it manually" (the file on disk was 0 bytes); `.stl` files greyed the button out; `.step` couldn't be previewed at all. The protocol-handler URL emitted by `frontend/src/utils/slicer.ts` for OrcaSlicer (`orcaslicer://open?file=<URL>`) and Windows/Linux Bambu Studio (`bambustudio://open?file=<URL>`) was built by plain string concatenation with no `encodeURIComponent()` — the macOS `bambustudioopen://<URL>` branch was already encoding correctly, which is why macOS users didn't see this. A stale comment block in the file claimed the browser preserves the URL in the query string so no encoding is needed; that's true for the browser-to-OS handoff but ignores that the slicer itself calls `url_decode()` on the received query (BS `post_init()` calls `url_decode` then `split_str`; OrcaSlicer's Downloader regex-extracts then `url_decode`). Any already-percent-encoded character in the download URL — most commonly `%20` from filenames with spaces, which Bambuddy's archive paths produce naturally — decoded to a literal space and the slicer's subsequent HTTP GET came back 0 bytes or 404. All three URL forms now `encodeURIComponent()` the file URL, so the slicer sees the correctly-encoded URL after its own `url_decode`. The comment block is corrected to document the actual invariant. Regression test in `slicer.test.ts` feeds the exact issue reproduction URL (`Toothpick%20Launcher%20Print-in-Place.3mf`) and asserts `%2520` appears in the generated `orcaslicer://` href — so any future refactor that drops the encoding fails CI. Thanks to @jsapede for the double-encoding diagnosis and @AllanonBrooks and @lunaticds for the original reports.

### Security
- **postcss bumped to 8.5.12 to clear GHSA-qx2v-qp2m-jg93** — moderate-severity advisory: PostCSS < 8.5.10 has an XSS via an unescaped `</style>` sequence in its CSS Stringify output. The caret range in `frontend/package.json` already accepted 8.5.12, so this is a lockfile-only bump; vite, autoprefixer, and `@tailwindcss/postcss` all dedupe onto the same 8.5.12 with no nested copies left in `node_modules`. PostCSS runs at build time only and Bambuddy doesn't pass user-controlled CSS through it at runtime, so the practical impact even on the older version was nil — this is hygiene + clearing the `npm audit` warning.


## [0.2.3.2] - 2026-04-22

### Improved
- **GCode Viewer Reshaped as an Archive Preview Tool** ([#963](https://github.com/maziggy/bambuddy/pull/963) follow-up) — PR #963 landed the embedded PrettyGCode viewer with a library file picker, a connected-printer selector with live WebSocket status, and auto-load of the currently-printing file. In practice those three didn't match Bambuddy's data model: the library file picker only listed `.gcode` files (Bambuddy stores `.gcode.3mf`), the printer selector wasn't useful when the real goal is previewing an existing archive, and the auto-load path had the same `.gcode`-filter gap as the picker. The viewer is now scoped to a single focused workflow — "show me the G-code for this archive" — reached from the Archives page 3D-preview button (menu item + the card-corner badge + list-row menu, all three paths navigate the same way). Entry URL is `/gcode-viewer?archive=<id>[&plate=<N>]`; the route falls through to the SPA catch-all so a full-page reload keeps the Bambuddy layout shell, with the iframe at `/gcode-viewer/?archive=<id>…` serving the raw viewer. Bed size is fetched from `GET /archives/{id}/capabilities.build_volume` (already parsing `printable_area` + `printable_height` from the 3MF's `Metadata/project_settings.config`) so any printer model renders the correct bed — 350×320×325 for H2D etc. — with no hardcoded per-model map to maintain. Multi-plate archives now surface a dedicated plate picker modal (`components/PlatePickerModal.tsx`) with thumbnails and object lists matching the existing Re-print modal's visual language; source-only 3MFs (no sliced gcode) show a `archives.platePicker.noGcode` toast instead of sending the user to an empty viewer. Behind the scenes: `GET /archives/{id}/gcode` accepts `?plate=N` and resolves the filename by integer-matching the suffix (zero-padded names like `Metadata/plate_01.gcode` now resolve as plate 1, fixing a class of picker-claimed-but-404 archives); `GET /archives/{id}/plates` gained a top-level `has_gcode: bool` flag so the frontend can suppress the picker when the archive is source-only; `printer_state_to_dict` now injects `name` and `model` into every WebSocket snapshot so consumers don't race a separate `/printers` fetch for proper labels. Removed from the viewer: printer selector + WS subscription, library file picker, `BAMBU_BED_SIZES` hardcoded map, auto-load-currently-printing, sidebar nav entry, 32 orphaned `gcodeViewer` locale keys, and the unreachable `ModelViewerModal` render paths on archive cards (the File Manager still uses `ModelViewerModal` for library file previews — scope preserved). Added test coverage: `?plate=N` happy path, zero-padded filename resolution, missing-plate 404, no-plate fallback to first, `?plate=0` 400 rejection, `has_gcode=true/false` branch, plus `PlatePickerModal.test.tsx` (6 tests covering render, plate-name label, onSelect payload, backdrop close, thumbnail fallback) and `printer_state_to_dict` name/model surfacing tests. A toast replaces the old silent empty viewer for source-only archives; reload stays in the Bambuddy layout; H2D previews no longer overflow the bed.

### Improved
- **Printer Card Shows Plate Name on Multi-Plate Prints** ([#881](https://github.com/maziggy/bambuddy/issues/881)) — When two printers were running different plates of the same multi-plate 3MF, the Printers page cards displayed the same file name on both and gave no visual way to tell them apart. The Queue view already showed the plate name by querying the archive's plate list; the Printers page didn't have that linkage. The `GET /printers/{id}/status` endpoint now returns `current_archive_id` (resolved by matching the MQTT `subtask_id` against `PrintArchive.subtask_id`, the same bridge introduced in #972 for restart-resume) and `current_plate_id` (parsed from the MQTT `gcode_file` path by a new shared `parse_plate_id` helper that's also used by the WebSocket push path, so plate transitions within a running print reflect immediately instead of waiting 30 s for the next REST poll). The card fetches plate metadata via the same `api.getArchivePlates()` call the Queue page uses — shared React Query cache keeps it cheap across polls — and renders the actual plate name (or a "Plate N" fallback) only when the source 3MF is multi-plate, so single-plate prints stay noise-free. Falls back to the previous `plate_(\d+).gcode` regex when there's no archive linkage (e.g. prints started directly from the printer LCD). Regression tests cover the plate-id extraction across Bambu Studio path shapes and the label-override precedence in `formatPrintName`. Thanks to @stringham for the follow-up and screenshot.

### Improved
- **Printer Card: Remove Redundant In-Widget "Clear Plate & Start Next" Button** — In expanded view, the "Next in queue" widget rendered its own `Clear Plate & Start Next` button inside a yellow-bordered card (`PrinterQueueWidget.tsx`) whenever the plate-clear gate was up and an auto-dispatch item was queued — on top of the card-level "Mark plate as cleared" button introduced by #939. Both POSTed to the exact same `/printers/{id}/clear-plate` endpoint with identical optimistic-update semantics, so in that one state combination users saw two visually distinct affordances doing the same thing. Removed the widget's button and its entire `needsClearPlate` render branch; the card-level button (which is unconditional when plate-clear is required, and therefore already handles the staged-only and empty-queue cases that the widget couldn't) is now the single entry point. The widget becomes a pure passive "Next in queue" preview linking to `/queue`. No backend change, no change to the plate-status pill placement inside the Status box (deliberately kept where it is), and no change to compact-view (Size S) behaviour — the `plateStatusPill` at `PrintersPage.tsx:2664/2671` and the icon-only round clear-plate button at `:2673` are untouched. Also dropped the now-dead `awaitingPlateClear` / `requirePlateClear` / `printerState` props from `PrinterQueueWidgetProps` and the matching call site at `PrintersPage.tsx:2810`, and the orphaned `queue.clearPlate` / `queue.plateReady` translations from all eight locale files (`queue.clearPlateSuccess` is retained — still used by the card-level button's success toast). The dedicated `PrinterQueueWidgetClearPlate.test.tsx` suite (654 lines) was removed since every test asserted the behaviour of the now-gone button; `PrinterQueueWidget.test.tsx` continues to cover the passive-link path. Thanks to @EdwardChamberlain for flagging the duplication in #1079.

### Fixed
- **Print Scheduler Reprints the Just-Finished Job When Queue Has One Item Left (H2D)** ([#1078](https://github.com/maziggy/bambuddy/issues/1078)) — On H2D, clearing the plate and starting the next (and only) queued item caused the printer to re-run the job it had just finished while the UI reported the queued one as started. With multiple items left the symptom was hidden by forward progress. Root cause: `_watchdog_print_start` in `print_scheduler.py` gives up at 45 s and reverts the queue item to `pending` if `gcode_state` hasn't flipped away from `pre_state`, on the assumption that a non-transitioning printer means the MQTT `project_file` publish was swallowed by a half-broken session (#887/#967). H2D Pro firmware (01.01.00.00) routinely keeps `gcode_state=FINISH` for 48–55 s after actually accepting the command before transitioning to `PREPARE` — logs from the reporter show the revert firing at +45 s and a legitimate `PRINT START detected` arriving just ~3 s later — so the watchdog reverted an item that the printer *had* already started physically printing. The physical print ran to completion and updated the linked archive (via `register_expected_print`), but the queue item was now `pending` again; on the next scheduler tick after the user cleared the plate, the same item was re-dispatched as if it had never run. With multiple items queued, item N+1 getting dispatched during the 45 s race window looked like forward progress to the user and masked the duplicate revert/re-dispatch of item N. Fixed in `_watchdog_print_start` by adding a second "command landed" signal: `subtask_id` changing past the pre-dispatch value. Bambuddy already mints a unique `submission_id` per `project_file` publish (capped at int32 post-#1042) and assigns it to `subtask_id` / `task_id` in the command payload; the printer echoes this back on the next `push_status` as soon as it starts processing — well before `gcode_state` transitions on slow-transition models. `_start_print` now captures `pre_subtask_id` alongside `pre_state` and passes both to the watchdog, which treats *either* a state change *or* a `subtask_id` advance as proof the command landed. Timeout raised 45 s → 90 s as belt-and-braces for printers that neither transition state nor echo `subtask_id` inside the polling window. None of the earlier exit paths are weakened — genuine half-broken sessions (state *and* `subtask_id` both unchanged across the full window) still revert, still force the MQTT reconnect, and are still recoverable without a power cycle. Added eight regression tests in `test_scheduler_watchdog.py` covering: pickup via state change, pickup via `subtask_id` change while state stays at `FINISH` (the exact #1078 case), revert when neither signal changes, default timeout of 90 s, `pre_subtask_id=None` fallback to state-only, `status.subtask_id=None` not mis-detected as a change, printer disconnect mid-watchdog (no DB write), and the `#967` race where the item already moved on (`completed`). No frontend or MQTT changes — purely tightens the "did the printer accept?" decision. Thanks to @VREmma for the clear reproduction and the full support bundle that made pinpointing the H2D state-lag behaviour possible.
- **Printers-Page "Clear Plate" Button Takes 30–300+ s to Appear After Print Completes** ([#939](https://github.com/maziggy/bambuddy/pull/939) follow-up) — A trusted user reported that on every printer (A1, H2D, X1C), the "Clear Plate & Start Next" button didn't show for 60+ seconds after a print finished; refreshing didn't help; one H2D sat in the "Finished" state for 5 minutes without the button ever appearing. Root cause: PR #939 added the `awaiting_plate_clear` gate but stored it on `PrinterManager._awaiting_plate_clear` (a per-process set, persisted to `printers.awaiting_plate_clear` via #961), not on `PrinterState` — and `printer_state_to_dict()` in `printer_manager.py`, which builds every WebSocket `printer_status` payload, was never updated to emit it. Only the HTTP endpoint `GET /printers/{id}/status` (line 634) surfaced the flag. That left the frontend in a deadlock: when `print_complete` arrived over the WebSocket, `useWebSocket.ts` intentionally *didn't* invalidate `['printerStatus']` (avoiding the render-cascade freeze the comment at line 235 warns about), expecting the subsequent `printer_status` WS messages to "naturally update the status" — but those messages carried no `awaiting_plate_clear` field, so the merge at line 146 preserved the stale `false`. The only path that ever surfaced `true` was the 30 s HTTP fallback poll at `PrintersPage.tsx:1430`, and on a chatty printer each incoming WS tick's `setQueryData` bumped React Query's `dataUpdatedAt`, pushing the next fetch further out — which is why the delay varied from ~30 s to several minutes. The plate-status pill at `PrintersPage.tsx:1672-1675` rendered "Plate Clear" (the fallback label for falsy `awaiting_plate_clear`) during the entire stale window, compounding the confusion. Fixed by emitting `awaiting_plate_clear` from `printer_state_to_dict`: the function already has `printer_id`, so it reads `printer_manager.is_awaiting_plate_clear(printer_id)` directly and returns `False` when no id is passed (for the few callsites that don't have one). No frontend change needed — the existing WS merge path now carries the flag end-to-end, the "Clear Plate" button appears instantly on completion, and the queue-dispatch side of the gate (which already reads the in-memory set directly via `print_scheduler.py:1125`) is unaffected. Regression tests in `test_printer_manager.py` assert the WS dict always contains the key and that it surfaces `True` when the manager has the flag set for that printer_id. Affects every printer equally because the path is transport-agnostic — not an H2D- or A1-specific problem, just more visible on H2D because its longer finish sequence gave the poll slip more opportunities to miss.
- **Printers-Page Search Turns Into a Password Field After Opening Change-Password Modal** — On the Printers page, clicking the key icon in the sidebar to open the Change Password modal caused the "Search printers" input to render as a password field (masked dots); closing the modal didn't restore it, requiring a full reload. Root cause: the Change Password modal has three `<input type="password">` fields but no accompanying username input, so password-manager browser extensions (1Password, Bitwarden, Chrome/Safari built-in) scanned the current DOM for a matching username anchor and latched onto the nearest `type="text"` input with no `name`/`autoComplete` — which happened to be the Printers-page search bar — and overrode its rendering. Fixed on two levels: (1) added a hidden `<input type="text" name="username" autoComplete="username" value={user.username} readOnly hidden>` at the top of the Change Password modal so password managers have a proper anchor and stop hunting elsewhere — as a bonus, saved new passwords are now correctly keyed to the logged-in user; (2) hardened the Printers-page search input with `type="search"`, `name="printer-search"`, `autoComplete="off"`, and `data-1p-ignore` / `data-lpignore="true"` so any future heuristic-based autofill also skips it.
- **AMS Slot Configure: Custom Cloud Preset Resolves to "Generic" in Slicer & Printer LCD** ([#1053](https://github.com/maziggy/bambuddy/issues/1053) follow-up) — After configuring any AMS slot (HT or regular) with a user custom Bambu Cloud preset built on top of a Bambu base profile (e.g. "Sting3D ABS" inheriting from "Generic ABS @BBL H2D"), OrcaSlicer's *Sync Filaments* continued to resolve the slot to "Generic ABS" and the custom preset never appeared on the printer's own LCD — independent of the earlier UI fix (commit `87a5aa36`) which only corrected Bambuddy's own modal. Root cause: when Bambu Cloud's `GET /cloud/settings/{setting_id}` returns a user preset with `filament_id: null` and `base_id: "GFSB99_07"` (cloud doesn't mint a distinct filament_id for presets that only override fields of a generic base), `ConfigureAmsSlotModal.tsx:382-384` fell back to `convertToTrayInfoIdx(base_id)` which strips the version suffix and the `S` prefix → `"GFB99"` — Generic ABS's filament_id. The printer accepted and reported back `GFB99`, so both the LCD and OrcaSlicer correctly resolved the slot to Generic ABS. The fallback was never right: the preceding default already set `tray_info_idx = convertToTrayInfoIdx(selectedPresetId)` which for any `PFUS*`/`PFSP*` setting_id returns the base setting_id itself (via the helper's `startsWith('PFUS')` branch added earlier), and the printer + both slicers round-trip that format unchanged — confirmed by existing backend integration tests (`test_configure_pfus_sent_directly`, `test_pfus_slicer_filament_used_directly`), by the print scheduler's slot-matching which already expects `P*` short-form IDs in the printer's reported `tray_info_idx` (`print_scheduler.py:910`), and by the inventory Assign Spool flow which has been sending `PFUS*` preset IDs to the printer for months. The buggy fallback *overwrote* the correct default with a generic mapping. Fixed by removing the base_id branch: when cloud detail carries a distinct `filament_id` we still prefer it, otherwise we keep the setting_id-derived default. BambuStudio Sync now resolves the custom preset cleanly; OrcaSlicer (whose user presets don't carry a `filament_id` field at all, only `inherits`) will continue to fall back to the inherited generic — that's an OrcaSlicer preset-format limitation, not something Bambuddy can fix on its side, and the behaviour is strictly not worse than before. Regression tests in `ConfigureAmsSlotModal.test.tsx` pin four paths: (1) cloud detail with `filament_id: null` → `tray_info_idx` is the `PFUS*` setting_id, (2) cloud detail with a concrete `filament_id` → that filament_id wins over the default, (3) GFS* Bambu presets skip the cloud-detail fetch entirely and still map to the short `GF*` filament_id, and (4) a 5xx / network error on the cloud-detail fetch degrades gracefully to the `PFUS*` default instead of aborting the configure flow. An end-to-end backend test (`test_configure_pfus_preserves_setting_id_pair`) locks in that both `tray_info_idx=PFUS…` and `setting_id=PFUS…` survive the HT-slot `POST /slots/{ams}/{tray}/configure` path untouched. Thanks to @mrnoisytiger for the detailed browser-console / network / backend-log diagnostic data that isolated the fallback path, and for sharing the OrcaSlicer preset JSON that showed the missing `filament_id` field.
- **Single Malformed `rgba` Bricks the Entire Filaments Inventory Page** ([#1055](https://github.com/maziggy/bambuddy/issues/1055)) — A user's Filaments page went blank and "Add Spool" became a no-op with no visible error. The backend was returning HTTP 500 from `GET /api/v1/inventory/spools` with `fastapi.exceptions.ResponseValidationError: rgba → 'FFFFFFF' should match pattern '^[0-9A-Fa-f]{8}$'` — a single legacy spool row had a 7-char rgba (missing one trailing `F`) and Pydantic's strict pattern on `SpoolResponse` refused to serialize the whole list because of it. Root cause spans three layers: (1) `SpoolUpdate` had no rgba pattern constraint, so PATCH calls could plant malformed values straight into the DB (`SpoolCreate` did validate, but only on initial create); (2) the `ColorSection` hex input's onChange ternary `val.length <= 6 ? 'FF' : ''` silently emitted 7-char strings for 5-char or 7-char typed input (5 chars + `FF` alpha = 7 chars; 7 chars got no alpha appended at all), which then flowed to the unvalidated PATCH endpoint; (3) `SpoolResponse` inherited the same pattern as `SpoolCreate`, so any malformed row already in the DB exploded the entire list endpoint on serialize even though write-side validation was the right place for the check. Fixed on all three layers: `SpoolUpdate.rgba` now carries the same `^[0-9A-Fa-f]{8}$` pattern as `SpoolCreate`, so PATCH requests with malformed rgba are rejected with 422 at the boundary. The hex input always emits a fully-formed 8-char RRGGBBAA on every keystroke — 8-char paste passes through, 7-char drops the stray char, shorter input is right-padded with `'0'` and given FF alpha. `SpoolResponse.rgba` is now an unconstrained `Optional[str]`: the pattern belongs on request schemas where Pydantic can reject bad input, not on responses where it turns a single bad row into a total page failure. A legacy malformed row still appears in the UI (the color just renders as whatever browser default applies) but the user can see, edit, and delete it instead of having to hand-edit SQLite. Backend tests cover all three schema contracts (16 cases across `SpoolCreate` accept/reject, `SpoolUpdate` accept/reject, `SpoolResponse` lenient-tolerance on 7-char / null / garbage). Frontend tests cover the hex-input normalization for every input length 0–8 plus non-hex strip-and-pad. Thanks to @fdsghy4a for the end-to-end debugging and for locating the exact malformed row in their DB.
- **Printer-Card "Print" Button Leaves Transient Copy in File Manager** ([#730](https://github.com/maziggy/bambuddy/issues/730)) — The "Print" button on a printer card (and the equivalent drag-drop-onto-card flow) was silently uploading the chosen file into the Library file manager as a side effect before printing. Root cause is structural: the frontend opened `FileUploadModal` to persist the file as a `LibraryFile`, then `PrintModal` dispatched a library print through `POST /library/files/{id}/print`, which uses the LibraryFile as the source for both the archive copy and the FTP upload to the printer. When the dispatch finished, both the `LibraryFile` row and its disk file in `data/library/` were left behind, so every one-off Direct-Print accumulated an unwanted File Manager entry that the user had to find and delete manually. The other three print entry points are untouched: Archive "Reprint" never involved the library, and File Manager "Print" / Project Detail "Print" are paths where the user deliberately put the file in the library, so their entries are preserved. `POST /library/files/{id}/print` now accepts an optional `cleanup_library_after_dispatch` boolean. When true, `_run_print_library_file` stages the LibraryFile row for deletion in the same transaction as the archive insert (so a mid-flight FTP or `start_print` failure rolls back both at once, leaving no orphan), commits together, then unlinks the library disk file and thumbnail from disk after commit succeeds. External library files (`is_external = True`, pointing at user-managed folders outside Bambuddy's control) are never touched regardless of the flag. The Printers-page Direct-Print flow is the only caller that sends `true`; every other `api.printLibraryFile` call site leaves the flag unset so default-False preserves their library entries. Added two unit tests at the enqueue level (default-false + flag-propagates-true), two integration tests at the endpoint level (default-false + forwards-true + cleanup flag never leaks into the MQTT options dict), and two frontend tests on `PrintModal` guarding that `cleanupLibraryAfterDispatch` only forwards when explicitly set — so future File Manager / Project Detail entry points can't accidentally inherit the Direct-Print semantics. Thanks to @3823u44238 for flagging the surprising side effect.
- **Direct / File Manager / Library Prints Still Unattributed to User** ([#730](https://github.com/maziggy/bambuddy/issues/730)) — The 0.2.3.1 fix (commit `f03d0c4c`) plumbed the authenticated user from `POST /library/files/{id}/print` into the background-dispatch job object, but the dispatcher itself never read it back out: `_run_print_library_file` called `ArchiveService.archive_print()` without the `created_by_id` parameter and never called `printer_manager.set_current_print_user()`. Net effect: direct prints from the printer-card "Print" button, File Manager prints, and Library prints all continued to land archives with `created_by_id = NULL` (invisible to the per-user stats filter), and the post-print email notification had no user to target. The dispatcher now forwards `job.requested_by_user_id` to the archive at creation time and registers the current-print user after `start_print` succeeds — matching the reprint path's behaviour. Reprint-from-Archive attribution is a separate bug (the reprint reuses the source archive row as-is, so a NULL `created_by_id` stays NULL) and is tracked on #730. Thanks to @3823u44238 for the thorough end-to-end retest.
- **Spoolman Iframe Blocked by CSP on HTTP Instances** ([#1054](https://github.com/maziggy/bambuddy/issues/1054)) — The Filament tab showed a blank page with a brief Spoolman flash on reload. Browser console reported `Content-Security-Policy: The page's settings blocked the loading of a resource (frame-src) at http://<host>:7912/spool because it violates the following directive: "frame-src 'self' https:"`. Root cause: commit `53a70e37` (#995) tightened the CSP to allow external sidebar iframes but only whitelisted `https:`, overlooking that self-hosted services on LANs — Spoolman, OctoPrint, etc. — almost always run over plain HTTP. The `frame-src` directive now allows `http:` as well (`frame-src 'self' http: https:`), matching the `connect-src 'self' ws: wss:` pattern already used for WebSockets. `frame-ancestors 'none'` still prevents Bambuddy itself from being framed cross-origin. Thanks to @saint-hh for reporting.
- **AMS-HT: Custom Filament Preset Reverts to "Generic" in UI After Configure** ([#1053](https://github.com/maziggy/bambuddy/issues/1053)) — After configuring an AMS-HT slot (HT-A/HT-B) with a custom Bambu Cloud preset (e.g. "Devil Design PLA Basic"), the slot card and Configure modal kept showing "Generic PLA" even though the `ams_filament_setting` command succeeded and BambuStudio / the printer's LCD both rendered the correct custom preset. Root cause: the `GET /api/v1/printers/{id}/slot-presets` endpoint keyed its response dict by `ams_id * 4 + tray_id`, which collapses cleanly to the same integer the frontend uses for regular AMS slots (0 through 15) but produces `128 * 4 + 0 = 512` for HT-A — a key nothing looks up. The frontend's PrintersPage HT render path calls `getGlobalTrayId(ams.id, …, false)` which returns the ams_id itself (`128` for HT-A), and SpoolBuddy's AMS page used a third, unrelated formula (`(amsId - 128) * 4 + trayId + 64 = 64`). All three agreed for regular AMS so the mismatch only surfaced on HT, where the saved preset name never reached the UI and the render fell through to `tray.tray_type` → rendered as "Generic PLA". Backend now keys the response via a `_slot_preset_key` helper that mirrors frontend `getGlobalTrayId` (HT → `ams_id`, regular/external → `ams_id * 4 + tray_id`), and SpoolBuddyAmsPage uses the shared `getGlobalTrayId` helper instead of its home-grown formula. Regression test covers the key scheme for regular, HT, and external slots. Thanks to @mrnoisytiger for the detailed reproduction.
- **⚠️ Bed-Jog "Home Z" Could Crash the Bed Into the Toolhead** ([#1052](https://github.com/maziggy/bambuddy/issues/1052)) — **Critical safety fix.** On H2C (and by extension any Bambu printer where Z-home moves the bed UP toward an endstop — H2D, H2S, and X1 family all share this kinematics) the bed-jog modal's "Home Z" button sent a raw `G28 Z` over the `gcode_line` MQTT command. Bare `G28 Z` skips the toolhead-park step that a full `G28` runs first, so the bed raised without stopping at a safe height — in the reporter's case the toolhead happened to be parked on the purge chute and no damage was caused, but hitting the button with a toolhead anywhere else would have driven the bed into it at full Z speed. Root cause was the `/api/v1/printers/{id}/home-axes` endpoint's per-axis gcode mapping (`"z" → "G28 Z"`, `"xy" → "G28 X Y"`, `"all" → "G28"`). The endpoint now ignores the `axes` argument entirely and always sends a bare `G28`, which Bambu firmware expands into the safe multi-step sequence (park toolhead → home XY → home Z). The MQTT client helper `BambuClient.home_axes()` has the same change. The bed-jog modal is retitled "Auto Home" and its copy now says "parks the toolhead, then homes X, Y, and Z" so users aren't surprised when X/Y motion happens first. After a successful Auto Home click, the modal no longer re-prompts on the next jog in the same session — the "not homed" warning is gated on a session-scoped acknowledgement flag that was only being set by "Move anyway" and now also fires on successful Auto Home. Regression test covers all three axes arguments producing the same bare `G28`. Thanks to @mikefromdot for catching this with an undamaged retest.
- **AMS: Configure / Assign Spool Hidden on Reset Slots, and Assign Spool Missing Matching-Material Inventory** ([#1047](https://github.com/maziggy/bambuddy/issues/1047)) — Two separate symptoms from the same report. (1) After resetting an AMS slot from the printer UI, the Bambuddy printer card showed "Empty Slot" with no Configure or Assign Spool actions on hover, while the same slot in SpoolBuddy's AMS page still let the user re-configure it. Root cause: commit `c9efa4b8` (#784) added a `tray?.state === 10` gate to the `EmptySlotHoverCard` actions, intended to show the buttons only when a spool was physically present but not loaded (state=10) and hide them on truly empty slots (state=9). In practice, firmware often reports `state=9` (or no `state` field at all) after a user-initiated reset — even when a spool is still physically in the slot — so the actions disappeared exactly when the user needed them. The gate is redundant anyway (`EmptySlotHoverCard` is only rendered when the slot has no `tray_type`, so it's definitionally empty from Bambuddy's perspective), and configuring an empty slot is a valid "tell the printer what will be loaded here" operation. The gate is now removed at both the standard-AMS and AMS-HT render paths. (2) After configuring a slot with a Generic profile (e.g. "Devil Design PLA Basic Red"), the Assign Spool modal didn't list the matching inventory spool unless the user enabled the "Show all spools" toggle. Root cause: the filter at `AssignSpoolModal.tsx:144` required `normalizeValue(spool.slicer_filament_name) === normalizeValue(trayInfo.profile)` — manually-added inventory spools typically don't have `slicer_filament_name` populated, so they failed the exact-profile check even when the material matched. The filter now prefers an exact slicer-profile match when both sides advertise one, and falls back to partial material match in either direction (so e.g. a spool with `material="PLA"` is selectable for a slot reporting `"PLA Basic"`) when profile info is missing. (3) Once the matching spool was assignable, a "profile mismatch" confirmation dialog still warned on every assignment because Bambu Studio / OrcaSlicer slicer-profile names carry a printer/nozzle/variant qualifier after `@` (e.g. `"Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)"`) while the tray stores only the bare base name (`"Devil Design PLA Basic"`), and `checkProfileMatch` compared the full strings. Both the filter and the mismatch check now strip the `@…` qualifier before comparing, so identical base profiles are treated as a match. Regression test covers a spool with no slicer profile being surfaced for a slot whose profile + material are both set. Thanks to @TravisWilder for the report.
- **Skip Objects: Enlarged Preview Image Fails to Load on Auth-Enabled Instances** ([#1046](https://github.com/maziggy/bambuddy/issues/1046)) — Clicking the mini print-pr


### Added
- **Spoolman Unified Inventory UI** — Replaced the Spoolman iframe with a native inventory UI that matches the local spool experience exactly. The Filament Inventory page auto-detects the active backend (local DB or Spoolman) and renders spools, filters, deep-links, and NFC write flows identically regardless of source. Spoolman spools are fully editable — material, weight, colour, storage location, cost — via a PATCH proxy that re-links the Spoolman filament on metadata changes. Bulk-create, archive, restore, and delete are all supported. A 207 Multi-Status response on partial bulk-create includes `requested_count` and `failed_count` so the UI can surface a useful "Created N of M" message.
- **Storage Location field** — Spoolman's `location` field is now exposed as `storage_location` in the unified inventory schema and editable from the spool detail panel.
- **Deep-link from AMS slot hover card** — Clicking the filament chip on an AMS slot hover card deep-links directly to the matching spool in the inventory, whether it lives in the local DB or Spoolman. The link resolves by spool ID so it survives list reloads; a 404 shows a "Spool not found" toast instead of a generic error.
- **Spoolman-aware NFC write** — The SpoolBuddy Write Tag flow now falls back to Spoolman when a spool is not in the local DB. The NDEF payload is built from the Spoolman filament metadata; incomplete fields (missing `color_name`, `nozzle_temp_min`, etc.) produce per-field warnings surfaced as a UI toast. After a successful NFC write the tag UID is persisted back to Spoolman's `extra.tag` via a safe key-merge that preserves all other custom extra fields.
- **Spoolman-aware scale weight sync** — The SpoolBuddy scale endpoint reads `filament.spool_weight` from Spoolman to compute the tare, falling back to 250 g when unset. Weight is written back to Spoolman's `remaining_weight`.

### Fixed
- **Tag-clear preserves other Spoolman extra keys** — Clearing a tag UID from the inventory PATCH endpoint previously sent `extra: {}`, destroying any custom Spoolman extra fields. The endpoint now fetches the current extra dict, drops only the `tag` key, and PATCHes the remainder.
- **Malformed Spoolman spool no longer 500s the entire inventory list** — A spool with a missing or non-positive `id` field caused `_map_spoolman_spool` to raise `ValueError`, crashing `GET /spoolman/inventory/spools` with HTTP 500. The list endpoint now logs-and-skips individual bad rows so the rest of the list is returned normally.
- **delete / archive / restore correctly return 404 on missing spool** — Previously these endpoints returned HTTP 500 when Spoolman responded with 404. The service layer now raises `SpoolmanNotFoundError` on 404 and `SpoolmanUnavailableError` on other failures; routes map them to 404 and 503 respectively.
- **SSRF guard applied to all SpoolBuddy Spoolman paths** — `_get_spoolman_client_or_none` now runs `assert_safe_spoolman_url` before initialising the client; unsafe URLs are silently ignored with a warning log so devices continue operating.

## [0.2.3.1] - 2026-04-20

### Fixed
- **Skip Objects: Enlarged Preview Image Fails to Load on Auth-Enabled Instances** ([#1046](https://github.com/maziggy/bambuddy/issues/1046)) — Clicking the mini print-preview thumbnail inside the Skip Objects modal opened a lightbox that showed a broken-image icon instead of the full-size plate preview. The thumbnail `<img>` wrapped its `src` with `withStreamToken()` (which appends the short-lived camera-stream token to `/api/v1/` URLs that `<img>` tags can't attach an `Authorization` header to), but the enlarged lightbox `<img>` used a bare `${status.cover_url}?view=top` so the browser's unauthenticated request was rejected by the backend. Both images now go through `withStreamToken()`. Thanks to @elit3ge for the report and screenshot.
- **P1S Print Dispatches Stuck at IDLE Due to task_id Int32 Overflow** ([#1042](https://github.com/maziggy/bambuddy/issues/1042)) — Since the #1011 fix switched `project_id` / `subtask_id` / `task_id` from hardcoded `"0"` to `str(int(time.time() * 1000))`, each submission sent a 13-digit epoch-millisecond value (~1.7×10¹²). P1S firmware (observed on 01.10.00.00) clamps oversized task identity fields to signed int32 max (`2147483647`), so every dispatch looked identical from the printer's perspective — it treated a fresh print as a continuation of the prior FAILED job, returned `result: success` for `project_file` (command accepted), but then sat at `gcode_state: IDLE` with an empty `gcode_file` instead of transitioning to `PREPARE`/`RUNNING`. Thanks to @EdwardChamberlain for pinpointing the exact line and suggesting the mod fix. The three identity fields are now set to `str(int(time.time() * 1000) % 2_147_483_647 or 1)`: modulo keeps values inside the signed-int31 window with a ~24-day uniqueness cycle (more than enough for reprint deduplication), and `or 1` guards against the astronomically unlikely zero case (the printer rejects `task_id=0`). Regression test `test_submission_id_fits_signed_int32` asserts all three IDs are `< 2**31`. Two of @EdwardChamberlain's other suggestions — resolving `bed_type` from the sliced 3MF's per-plate JSON instead of hardcoding `"auto"`, and gating dispatch success on an actual state transition to `PREPARE`/`RUNNING` rather than on `project_file`'s `result: success` — are larger changes tracked separately.
- **FTP Download Zombie-Thread Race on Slow WiFi** ([#1014](https://github.com/maziggy/bambuddy/issues/1014)) — Users on 2.4 GHz WiFi with heavy neighborhood interference saw "Successfully downloaded" log lines for queued prints that Bambuddy nonetheless reported as failed, and the slicer file landed in `/app/data/archives/temp/` with the File Manager unable to find it. Root cause: `download_file_async` wrapped the blocking FTP `RETR` in `asyncio.wait_for` with a 30–60 s timeout (user-configurable via `ftp_timeout`), but the wrapped thread couldn't be cancelled. On a slow link the download would overshoot the timeout by 15–30 s, at which point `_run()` waited a hard-coded 0.5 s for the zombie to finish, gave up, and returned failure — which triggered `with_ftp_retry` attempt 2, whose `_download` spawned a brand-new FTP session that contended with attempt 1's still-running transfer. Attempt 1's zombie eventually completed and wrote the file to disk, but by then attempt 2 (and 3, 4) had long since run out their own timeouts with their own fresh `completion` dicts and reported failure; the archive pipeline saw only the final `None` from `with_ftp_retry` and created a fallback archive row with no 3MF data, which is why Skip-Object couldn't find the plate's objects even though the 3MF was on disk. Two fixes: the 0.5 s post-timeout sleep is replaced with a `threading.Event` the worker sets in its `finally` block, and `_run()` waits for that event with a bounded grace of `max(min(ftp_timeout, 30), 0.5)` s — covering the slow-WiFi overshoot case without extending a genuinely stuck connection indefinitely. The log line now includes the grace window (`timed out after Xs (plus Ys grace)`). Regression test `test_download_file_async_timeout_waits_for_slow_zombie` simulates a 1.5 s zombie with a 1.0 s wait_for timeout; old 0.5 s sleep would give up, new 1.0 s grace salvages. The existing `test_download_file_async_timeout_no_salvage_when_incomplete` still passes — a thread that never completes within the grace window still returns failure. Thanks to @heffe2001 for the detailed reproduction and support logs.
- **Obico: Cold-Start Capture Timeout Sticks in Status Banner** ([#172](https://github.com/maziggy/bambuddy/issues/172)) — On the very first detection poll after a restart, the initial RTSP snapshot capture occasionally exceeded the 20 s `SNAPSHOT_CAPTURE_TIMEOUT` (the first keyframe from the printer's camera can take a while on a cold RTSP connection). Subsequent polls every ~8 s recovered and captured in ~1.2 s, but the red `× Failed to capture snapshot for printer N` banner in Settings → Failure Detection → Status stayed up forever because `ObicoDetectionService._last_error` was written on failure and never cleared on the next successful poll. The successful branch in `_check_printer` now clears `_last_error` to `None` once a capture + ML call + classification complete, so the banner reflects only errors from recent cycles. Configuration-level errors (missing `external_url`, missing `ml_url`) still persist because they return before the clearing line — users still see them until they fix the setting. Regression test covers: seed `_last_error`, run one successful `_check_printer`, assert `_last_error is None`. Thanks to @fblix for the reproduction and screenshot.
- **Printer Card Controls Row Overflows in Chrome** — At Medium card size on a wide viewport, the printer-card controls row (fan badges, airduct mode, print speed, bed jog, then Stop / Pause on the right) visibly overlapped in Chrome while rendering fine in Firefox and Safari. The controls-row layout had a `max-[550px]:flex-wrap` rule on the left badge group that only fires below 550 **viewport** pixels, so on a wide viewport with a narrow card the left group never wrapped — and since its badges don't truncate, Chrome painted the overflowing speed/bed-jog badges on top of the right-pinned Stop/Pause buttons. German locales made it obvious ("Pausieren" is 9 characters). The left group now uses unconditional `flex-wrap`, so when badges don't all fit on one line they wrap inside the left cell instead of colliding with the right cell; the parent row also wraps `gap-y` so Stop/Pause drops to a new line in the worst case. Pre-existing (commit `4ff3e2a6`, Feb 2026), surfaced while testing #939.
- **MQTT Smart Plug Subscription Lost After Every Restart** ([#1010](https://github.com/maziggy/bambuddy/issues/1010)) — Users integrating a Shelly (or any other) plug through an external MQTT broker (e.g. ioBroker, Zigbee2MQTT, Home Assistant's MQTT broker) saw the plug's power / state / energy readings go dark after every Bambuddy restart, and the only fix was to open Settings → Smart Plugs, rename the topic to a dummy value, save, rename it back and save again. Root cause: the startup restore path in `main.py` (~line 4120) still used the legacy single-topic model (`mqtt_topic` plus `*_path` kwargs), while the Settings UI save path had been upgraded to the newer per-type model (`mqtt_power_topic` / `mqtt_energy_topic` / `mqtt_state_topic` each with their own paths, multipliers and `mqtt_state_on_value`). Plugs configured entirely with the new per-type fields got skipped at startup because the `if plug.mqtt_topic:` guard short-circuited — which is exactly what a Shelly-via-ioBroker setup looks like, since those publish power and state on separate topics. The "rename, save, rename back" workaround triggered the update endpoint, which was using the correct per-type code and re-established the subscription. Fix: extracted the topic-resolution + `service.subscribe()` call into a single `subscribe_plug_to_mqtt(service, plug)` helper in `backend/app/services/mqtt_smart_plug.py` that preserves legacy fallback, and routed the startup restore, create, and update routes all through it so future schema changes can't cause the three paths to drift again. Regression tests cover: per-type topics restored without a legacy topic set, legacy single-topic backward compat, per-type multipliers overriding legacy, per-type winning when both are set, the empty-config skip case, and topic-list de-duplication. Thanks to @saint-hh for the clear repro steps.
- **Large 3MF Uploads Archived as Corrupted ZIPs** ([#1032](https://github.com/maziggy/bambuddy/issues/1032)) — On bare-metal Raspberry Pi installs (armv7l / Python 3.11 / Bookworm), 3MF files larger than a few MB arrived complete via the virtual-printer FTP server but the copy into `data/archives/` ended up not being a valid ZIP. The archive row was still written, the printer card looked fine, and the problem only surfaced later when opening the archive in the UI, where `GET /archives/{id}/plates` logged `Failed to parse plates from archive N: File is not a zip file` and the thumbnail / plate / filament panels came up blank. Two things conspired: `shutil.copy2` takes the Linux `sendfile()` fast path on Python ≥ 3.8, and a partial-return from that syscall silently truncated the destination for the upload sizes users hit; and `ThreeMFParser.parse()` had a bare `except: pass` around its `zipfile.ZipFile` open, so the archive pipeline kept going with empty metadata and left the bad file on disk. The copy is now an explicit chunked read/write with `fsync()` — no sendfile involved — with a post-condition `zipfile.is_zipfile()` check that refuses to create the archive row (and cleans up the archive directory) when the source was a valid ZIP and the destination isn't, logging both sizes at `ERROR`. The parser's silent catch now logs at `WARNING` so corrupted 3MFs are visible in support bundles instead of disappearing into empty metadata. Regression tests cover small / multi-chunk copies, ZIP roundtrips, the post-copy `is_zipfile` sentinel on a truncated file, and the new parser WARNING. Thanks to @saint-hh for the detailed diagnosis.
- **Thumbnails Blank Until Reload After Sign-In** — On auth-enabled instances, signing out and back in left the File Manager (and occasionally the Archives page) full of broken thumbnails until the page was manually reloaded. Thumbnail URLs are gated by a short-lived camera-stream token that `<img>` tags can't send via `Authorization` headers, so the token is appended as `?token=…` at render time. Two race conditions conspired to break this: (1) the token query was keyed only on `['camera-stream-token']` and fired while the user was still on the login page, 401'd, and stayed cached — after sign-in nothing invalidated it; (2) when the token did eventually arrive, the global variable holding it was not reactive, so any File Manager / Archives page that had already rendered kept serving image URLs with no token. The token query now includes the user id in its key and is gated on `!!user`, so a new login always triggers a fresh fetch; and when the token transitions from null to a value, `useStreamTokenSync` walks the DOM once and updates `src` on every already-rendered `<img>`/`<video>` pointing at `/api/v1/` without the current token, reloading them in place.
- **P2S Firmware Check Shows Stale "Latest" Version** ([#1030](https://github.com/maziggy/bambuddy/issues/1030)) — On P2S (and X2D) the Firmware Info modal reported `01.01.01.00` as the newest available release even though `01.02.00.00` had shipped on the Bambu Lab wiki weeks earlier, so the "update available" badge never appeared. Two silent regex mismatches in the wiki scraper caused `_fetch_all_versions_from_wiki()` to return an empty list: (1) the section-heading anchor parser required a dash between the version bytes and the release date (`id="h-01020000-20260409"`), but P2S and X2D publish anchors without the dash (`id="h-0102000020260409"`); (2) the text-based fallback only accepted ASCII parens around the date, while P2S, X2D, A1 and A1-mini headings render dates in full-width `（YYYYMMDD）` (U+FF08/U+FF09). When both paths failed, the code silently fell back to the Bambu Lab download page, which still lagged at `01.01.01.00`. The anchor regex now accepts an optional dash and the fallback accepts both paren styles; added regression tests for the no-dash anchor and full-width paren shapes. Thanks to @Minebuddy for reporting.
- **Library File Print-Usage Tracking** ([#1008](https://github.com/maziggy/bambuddy/issues/1008)) — `LibraryFile.print_count` and `last_printed_at` are now updated on every successful queued print completion. Previously both fields were defined on the model and displayed in the File Manager, but nothing ever wrote to them — every file in every library showed as never printed. Now counts increment cumulatively and `last_printed_at` stamps the completion timestamp (UTC). Failed, cancelled and user-aborted prints are intentionally excluded, so the fields represent "successful usage" rather than "attempted usage." This unblocks sorting the File Manager by last-printed date and is a prerequisite for the scheduled-purge feature requested in #1008. Thanks to @cadtoolbox for the report.

### Improved
- **Color Catalog Default Filter Set to "All Manufacturers"** ([#1039](https://github.com/maziggy/bambuddy/issues/1039)) — Settings → Color Catalog opened with the manufacturer dropdown pre-filtered to *Bambu Lab*, so users searching for a third-party color had to change the dropdown to *All Manufacturers* on every visit. The page now defaults to *All Manufacturers* and lets you narrow down from there. Thanks to @VID-PRO for the suggestion.
- **File Manager: Collapse Folders by Default** ([#996](https://github.com/maziggy/bambuddy/issues/996)) — Added a **Collapse** toggle next to **Wrap** in the File Manager sidebar header. When enabled, the folder tree opens with only top-level folders visible on every page load; disabling it restores the previous fully-expanded default. Toggling the preference also immediately re-collapses/re-expands the current tree — no reload required. Persisted to localStorage under `library-collapse-folders`, matching the existing `library-*` preference pattern. Thanks to @AshieTashi for the request.

### Changed
- **Docker runtime image on Debian Trixie** — The production Docker image now builds on `python:3.13-slim-trixie` instead of the Bookworm-based `python:3.13-slim`. Picks up ffmpeg 5 → 7 (HEVC/AV1 improvements for camera capture), OpenSSL 3.0 → 3.3, and two more years of APT package freshness. Frontend-builder stays on Bookworm until the Node.js image team publishes Trixie variants — users never see that stage.

## [0.2.3] - 2026-04-19

### New Features
- **Move Build Plate from Printer Card** ([#791](https://github.com/maziggy/bambuddy/issues/791)) — The printer card controls row now has a Z-jog badge between the speed control and the stop/pause buttons. Click the up/down arrows to move the build plate; click the middle label to switch the step size (1 / 10 / 50 mm). When the printer is not homed (typical right after a print finishes), the first jog opens a Bambu Studio-style warning modal with **Home Z**, **Move anyway** (bypasses soft endstops for this move), or **Cancel**. After the first "Move anyway" in a session, subsequent jogs skip the dialog. Disabled while a print is running. Backed by new `POST /printers/{id}/bed-jog` and `POST /printers/{id}/home-axes` endpoints, both gated behind `printers:control`. Thanks to @cadtoolbox for the request.
- **Printer Card Status Badges & Quick Controls** — The Printers page printer card now exposes new at-a-glance controls inspired by the Home Assistant Bambu Lab integration:
  - **Enclosure Door badge** in the top status row (DoorOpen/DoorClosed icons, green when closed, yellow when open). Detection uses the right MQTT field per printer family — `home_flag` bit 23 on X1/X1C/X1E and the top-level `stat` hex string bit 23 on P1/P2/H2 — and falls through the existing WebSocket push (status-change dedup key now includes door state, so toggling the door alone triggers a live badge update without waiting for the 30 s REST poll).
  - **Airduct Mode badge** beside the print speed control (Snowflake/Flame icons, sky for Cooling and orange for Heating). One-click dropdown switches the printer between cooling and heating via the existing `set_airduct` MQTT command. Gated to P2S/H2D/H2C/H2S.
  - **Force Refresh** menu entry in the printer card kebab menu (RotateCw icon) that re-requests a full `pushall` MQTT status report from the printer without forcing a reconnect.
- **AI Print-Failure Detection via self-hosted Obico ML API** ([#172](https://github.com/maziggy/bambuddy/issues/172)) — New Settings → Failure Detection tab wires Bambuddy to a self-hosted [Obico](https://github.com/TheSpaghettiDetective/obico-server) `ml_api` container (no Obico account, no cloud, no WebSocket). While a print is running, the detection service periodically hands the printer's camera snapshot URL to the ML API, which returns YOLO failure-detection scores. Scores are smoothed over time using Obico's own EWM + short/long rolling-mean math (30-frame warmup, alpha = 2/13, short window ≈ 5 min at 10s/frame, long window ≈ 20 h) so a single noisy frame cannot trigger an action. Sensitivity (Low / Medium / High) scales the LOW/HIGH thresholds; when the smoothed score crosses HIGH, the configured action runs exactly once per print: *Notify only*, *Pause print* (MQTT pause command), or *Pause and cut power* (pause + turn off any smart plug linked to that printer). A per-printer toggle lets you monitor all connected printers or just a subset. The Status card shows whether the service is running, the active thresholds, each monitored print's current verdict (safe / warning / failure), and a live rolling detection history. Snapshots are captured locally with a 20 s timeout we control and stashed under a one-shot 32-byte nonce; the ML API fetches them via an unauthenticated `/api/v1/obico/cached-frame/{nonce}` URL that sidesteps Obico's hardcoded 5 s read timeout.

### Improved
- **Firmware Update Modal Shows All Announced Versions** ([#568](https://github.com/maziggy/bambuddy/issues/568)) — The firmware update dialog now lists every version announced on Bambu Lab's wiki release history, not just the single newest one. Each row shows whether an offline firmware file is actually available for that version — rows marked **Usable** (green) can be installed, rows marked **Unavailable** (gray) are announced but have no downloadable package yet (common for hot-fix releases like `01.01.03.00` which Bambu only ships as OTA). The currently installed version is highlighted with a blue **Installed** badge. Selecting any usable row swaps the release-notes block at the top to that version's notes and enables the Install button for it — including older-than-current versions, so you can roll back to a previous firmware without having to hand-flash a file. The wiki scraper was tightened to only extract version numbers from heading anchors (e.g. `id="h-01030000-20260303"`) so incidental version mentions in release-note prose — like an AMS firmware reference in an H2D changelog — no longer get mistaken for H2D firmware releases. Thanks to @Cornelicorn for the request.
- **Spoolbuddy Device Controls in Settings** ([#962](https://github.com/maziggy/bambuddy/issues/962)) — Each Spoolbuddy device card in Settings → Spoolbuddy now exposes five one-click actions alongside the existing Unregister button: Update (trigger daemon software update), Restart Browser (kiosk UI), Restart Daemon, Reboot (device), and Shutdown. Each action shows a confirmation dialog before queueing the command; buttons are disabled when the device is offline. Uses the existing `/spoolbuddy/devices/{id}/update` and `/spoolbuddy/devices/{id}/system/command` endpoints — no new backend work needed. Thanks to @TravisWilder for the request.
- **Support Bundle Covers All Settings & SpoolBuddy** — The support bundle / bug-report payload now dumps every row in the `Settings` table instead of filtering by a hard-coded allowlist: sensitive keys (tokens, passwords, URLs, paths, emails, etc.) have their values replaced with `[REDACTED]` but the key itself is kept, so new config flags automatically show up in future bundles without a code change. Also adds an `integrations.spoolbuddy` section listing registered SpoolBuddy devices (firmware version, NFC/scale hardware, calibration, online state, uptime) — anonymized, no hostnames/IPs/device IDs.
- **Settings Search Finds More Cards** — The cross-tab search field at the top of Settings now finds Sidebar Links, Spoolman, Spool Catalog, Color Catalog, all four Failure Detection sections, Advanced Email Authentication, SMTP Test, Authenticator App (TOTP), Email OTP, 2FA Linked Accounts, Single Sign-On (OIDC), LDAP Server Configuration, and the four Backup sub-cards (GitHub, History, Local, Scheduled). Powered by a new module-level registry (`frontend/src/lib/settingsSearch.ts`) so future settings register themselves next to their component instead of being forgotten in a central array.

### Changed
- **Plate-Clear Confirmation Disabled by Default** — New installs ship with Settings → Workflow → "Require Plate-Clear Confirmation" off. Multiple new users reported queued prints appearing to not start because the prompt was waiting for acknowledgement; opt in from Workflow if you want the confirmation gate.

### Security
- **Dependency Updates for Published Advisories** — Bumped two dependencies flagged by vulnerability scanners. `python-multipart` 0.0.22 → 0.0.26 closes CVE-2026-40347 (GHSA-mj87-hwqh-73pj), a denial-of-service triggered by large preamble or epilogue data around a multipart boundary — the 0.0.26 release now skips the preamble before the first boundary and silently discards the epilogue after the closing one. Bambuddy uses `python-multipart` transitively through FastAPI/Starlette for form and file-upload parsing, so any authenticated endpoint accepting `multipart/form-data` (e.g. backup restore, project thumbnail upload) was exposed. `dompurify` 3.3.3 → 3.4.0 picks up the fix for GHSA-39q2-94rc-95cp (the function-form `ADD_TAGS` could bypass `FORBID_TAGS`); Bambuddy's two call sites (`ProjectDetailPage`, `ProjectPageModal`) only use array-form `ALLOWED_TAGS`/`ALLOWED_ATTR`, so the specific bypass was not reachable, but the bump still hardens the sanitizer against future misconfiguration and clears the audit warning.

### Fixed
- **Virtual Printer "Synchronizing device information" Timeout with OrcaSlicer on Linux** ([#927](https://github.com/maziggy/bambuddy/issues/927)) — Follow-up to the b069b521 serial-adaptation fix. OrcaSlicer's Linux builds publish MQTT payloads with the C-string null terminator included in the length (same pattern as [paho.mqtt.c #1198](https://github.com/eclipse-paho/paho.mqtt.c/issues/1198)), so every decoded message arrived as `{…}\x00`. The virtual printer's strict `json.loads()` raised `JSONDecodeError: Extra data` and the handler silently returned — no pushall, get_version, or project_file was ever answered, so the slicer hit its 60 s sync timeout and reconnected in a loop. Real Bambu firmware's mosquitto passed the trailing byte through, which is why direct LAN connections worked, and why print_queue mode was the only affected path (proxy mode tunnels MQTT to the real printer instead of running the VP broker). The handler now strips trailing `\x00`/whitespace before parsing and logs the raw payload on any remaining decode failure so future silent variants are visible in support bundles. Thanks to @EdwardChamberlain for the debug-enabled support log that made the null byte visible in the raw bytes.
- **SpoolBuddy Kiosk Unusable After Full-Mode Install** — A bundled Bambuddy + SpoolBuddy install via `spoolbuddy/install/install.sh --mode full` produced an unusable kiosk on first boot: Chromium raced ahead of uvicorn and showed "can't connect to localhost"; after a manual reload the kiosk URL `/spoolbuddy?token=…` was hijacked by Bambuddy's first-run wizard (`AuthContext` force-redirects to `/setup` whenever `requires_setup=true`, regardless of the target path); the wizard asks for admin credentials, but a touch-only Pi has no on-screen keyboard; if the user skipped auth the browser landed at `/` instead of the kiosk, and if they tried to enable auth they were stranded. Standalone mode was unaffected because it runs against an already-configured remote Bambuddy. Fixed in three parts: (a) new `backend/app/cli.py` with a `kiosk-bootstrap` subcommand that in a single DB transaction creates a scoped API key (`can_read_status=True`, `can_queue=False`, `can_control_printer=False`) and upserts `setup_completed=true`, so the first-run wizard never triggers and the kiosk URL loads the SpoolBuddy page directly; users can still enable authentication later from the admin UI and the pre-provisioned key keeps working. (b) `install.sh` full-mode now runs the CLI as the bambuddy service user immediately after `create_bambuddy_service` and `sed`-replaces the `CHANGE_ME_AFTER_SETUP` placeholder in `spoolbuddy/.env`. (c) The generated `spoolbuddy-kiosk-launch` now polls `${backend_url}/health` with a 60 s timeout before exec'ing Chromium, so cold boots wait for uvicorn instead of flashing the connection-refused error. The CLI is idempotent with `--force` for re-installs.
- **Bambu Lab X2D Support** ([#988](https://github.com/maziggy/bambuddy/issues/988)) — Added X2D to the Add Printer and Edit Printer model dropdowns (both were missing the new model, so manual printer setup had no X2D option — auto-discovery via SSDP was unaffected). The newly released X2D (dual-nozzle, enclosed, hardened steel rod gantry, AMS 2 Pro compatible) identifies itself as internal model code `N6` via SSDP/MQTT, and serials begin with `20P9`. Because neither the code nor the prefix existed in any of Bambuddy's model tables, multiple paths silently fell back to wrong defaults: the camera service routed to the chamber-image protocol on port 6000 (which the X2D doesn't speak) instead of RTSP on port 322 — the reporter saw `Chamber image: data is not a valid JPEG` spam and no stream; the K-profile edit/delete path conditioned its in-place `cali_idx` write on the H2D serial prefix `094` and would therefore have treated X2D as a single-nozzle printer even though its dual-extruder layout matches H2D; the firmware-update check logged `Unknown printer model: N6`; and the virtual-printer model registry had no way to emulate X2D. Added the `N6 → X2D` mapping across every registry (`PRINTER_MODEL_ID_MAP`, `PRINTER_MODEL_MAP`, `ETHERNET_MODELS`, `STEEL_ROD_MODELS`, `CHAMBER_TEMP_SUPPORTED_MODELS`, firmware-check API keys and wiki path, virtual-printer SSDP product names and serial prefix, DB migration `vp_model_fixes`), extended `supports_rtsp()` to match `X2` display names and the `N6` internal code (camera now goes to port 322), expanded the dual-nozzle serial prefix check in `kprofiles.py` and the K-profile delete command in `bambu_mqtt.py` to also accept `20P9` so the H2D-style `cali_idx` in-place edit path runs on X2D, added X2D to the `is_h2d` model-family gate that selects the integer-format `timelapse`/`bed_leveling`/`flow_cali`/`vibration_cali`/`layer_inspect` fields in the MQTT print command, and added X2D to the frontend's door-badge and airduct-mode whitelists, `mapModelCode` lookups on both the Printers page and Spoolbuddy AMS page, and the MaintenancePage wiki-URL resolver (X2D inherits P2S's steel-rod lubrication, belt-tension, nozzle cold-pull and PTFE wiki pages, since its hardware is closer to P2S than to H2). Credit to @krautech for the report and the debug bundle, and to @legend813 for the initial PR (#989) that seeded most of the registry changes — the classification was corrected (X2D uses hardened steel rods like P2S, not carbon rods) and the dual-nozzle/K-profile gaps were added on top.
- **Print Speed Icon Not Updating Live When Changed on Printer** ([#993](https://github.com/maziggy/bambuddy/issues/993)) — Changing the print speed mode from the printer's own panel (instead of from Bambuddy) did not update the speed icon on the Printers page card; the new value only appeared after a full page reload. The MQTT parser was already tracking `spd_lvl` and updating `state.speed_level` correctly, but the WebSocket serializer (`printer_state_to_dict`) was missing the field — so live status pushes never carried `speed_level`, and the frontend's merge-over-old-cache update left the icon stuck on its previous value. The REST `/status` endpoint used on initial page load already included it, which is why reloads worked. Added `speed_level` to the WebSocket payload. Thanks to @chesterakl for reporting.
- **Camera Popup Shows "Valid camera stream token required" With Auth Enabled** ([#979](https://github.com/maziggy/bambuddy/issues/979)) — When Camera View Mode was set to "Window" and authentication was enabled, clicking the camera button opened a popup that immediately failed with `"Valid camera stream token required"`, while the embedded overlay kept working. Two root causes: (1) `window.open(...)` passed `noopener` in the popup features, which severed the opener link and prevented the browser from copying sessionStorage (where the auth token lives) into the popup — so the new window booted unauthenticated and the `POST /printers/camera/stream-token` fetch returned 401, leaving the `<img>` src without the required `?token=` query param; (2) even once the token arrived, `CameraPage` computed its URL from the module-level stream-token cache on render and never re-rendered when the cache was updated in a `useEffect`, so the first paint locked in a tokenless URL that the backend kept rejecting. Fixed by dropping `noopener` from the camera popup features (same-origin, trusted window) so sessionStorage is inherited, subscribing `CameraPage` to the `camera-stream-token` React Query so it re-renders the moment the token resolves, and appending the token directly from the reactive query value instead of the effect-synced module cache — the `<img>` src stays empty until the token is ready, so no tokenless request ever leaves the popup. Embedded-overlay mode was unaffected. Thanks to @VREmma for the reproducer.
- **AMS Slot Changes Stop Reaching Printer After Long Idle** ([#887](https://github.com/maziggy/bambuddy/issues/887)) — After printers sat idle for several hours, spool changes published by Bambuddy silently stopped reaching the printer — the UI updated but the printer ignored the command, and only a manual reconnect restored functionality. Root cause: the MQTT connection degraded into a zombie state where the receive path still worked (push_status telemetry kept flowing, so Bambuddy considered the connection alive) but the publish path was dead. The existing zombie detector — the developer mode probe — only ran on first connect when `developer_mode` was unknown; after the initial probe cached the value, subsequent zombie states went undetected because neither the staleness timer nor the keepalive could distinguish a half-open connection from a healthy one. The MQTT client now tracks `ams_filament_setting` command/response pairs: when a published command receives no response within 10 seconds, it's counted as unanswered. After two consecutive unanswered commands, the session is force-reconnected using the same `force_reconnect_stale_session()` mechanism. This catches zombie sessions at the moment the user encounters them — on their second failed spool change — rather than requiring a manual reconnect. Thanks to @RosdasHH for the detailed support bundles that made the diagnosis possible.
- **Obico Detection ML API Call Fails Silently With Empty Error** ([#172](https://github.com/maziggy/bambuddy/issues/172), [#1003](https://github.com/maziggy/bambuddy/issues/1003)) — The previous attempt at #1003 (0.2.3b4 dev) switched Bambuddy to **POST the JPEG bytes directly** to Obico's ML API as multipart form data, hoping to eliminate the callback-URL dependency for users behind reverse proxies with external auth. That approach cannot work: Obico's `/p/` endpoint is declared `methods=['GET']` upstream and only reads `?img=URL` as a query string (verified against `obico-server/ml_api/server.py`). Flask's router rejected every POST with 405 Method Not Allowed before any handler ran, which is why the Obico container logs showed zero activity while Bambuddy kept reporting `ML API call failed for printer N:` with a blank suffix — `raise_for_status()` on the 405 response produced an exception whose `str()` rendered empty in this path. Reverted to the pre-#1003 nonce-URL approach: the detection loop captures the JPEG locally with a 20 s timeout, stashes it under a 32-byte single-use nonce, and hands Obico a `GET /api/v1/obico/cached-frame/{nonce}` URL that resolves in <50 ms (so Obico's hardcoded 5 s read timeout never races our RTSP keyframe wait). The cached-frame route is un-authenticated at the Bambuddy layer — the unguessable 32-byte nonce with ~30 s TTL IS the credential. The warning log now also falls back to `type(exc).__name__` when `str(exc)` is empty, so future silent exceptions can never produce a blank error again. **For users behind reverse-proxy external auth (Authelia/Authentik/Cloudflare Access)**: the `/api/v1/obico/cached-frame/` path must be whitelisted from external auth — it's already public on Bambuddy's side. Thanks to @fblix for the ml-api-shows-zero-logs clue that pinpointed the 405 root cause.
- **Obico Detection Snapshot Killed by Stream Cleanup** ([#172](https://github.com/maziggy/bambuddy/issues/172)) — Third wave of #172 — once the cached-frame fix landed, `fblix` reported a permanent "Failed to capture snapshot" warning in the UI. The periodic camera stream cleanup task scans `/proc` for ffmpeg processes with Bambu RTSP URLs and kills any that aren't in the active-streams registry. The Obico detection service's `capture_camera_frame_bytes()` spawns its own short-lived ffmpeg process to grab a single JPEG frame, but that process was never registered with the stream cleanup — so when the 60-second cleanup cycle happened to run during the 5–10 s capture window, it killed the ffmpeg as "orphaned" (exit code -9). The detection service recovered on the next poll, but the kill produced unnecessary error logs and a missed detection frame. Fixed by tracking capture PIDs in a module-level set (`_active_capture_pids`) and excluding them from the `/proc`-scan kill list. Thanks to @fblix for the detailed timing analysis.
- **Direct Print from Library Not Attributed to User** — Clicking the Print button on a library file dispatched the job with no `created_by_id`, so the resulting archive had no owner and the print didn't show up in per-user statistics. The Queue and Reprint paths already forwarded the authenticated user; the library `POST /files/{file_id}/print` endpoint now does the same, reading the user from the JWT and passing it through to the dispatcher so direct prints are attributed like queued and reprinted ones.
- **Add/Edit Printer Modal Clipped on Short Viewports** ([#964](https://github.com/maziggy/bambuddy/issues/964)) — On short or zoomed-in browser windows, the Add Printer and Edit Printer dialogs exceeded the viewport height with no scroll, hiding the lower fields (Access Code, Model, Location) and the Save button. Users had to zoom the browser out to complete the form. The modal overlay now scrolls and the card caps at `calc(100vh - 2rem)` with internal overflow so every field stays reachable regardless of viewport height. Thanks to @MartinNYHC for reporting.
- **AMS Drying Silently Does Nothing** ([#971](https://github.com/maziggy/bambuddy/issues/971)) — Clicking Start Drying on a supported printer (e.g. P1S with AMS 2 Pro) could publish the MQTT command successfully but leave the AMS idle with no UI feedback. Two issues: (1) the firmware rejects the command when `dry_sf_reason` reports a blocking state (most commonly code 8 — AMS 2 Pro external power adapter not plugged in — but also "AMS busy", "already drying", etc.), and Bambuddy parsed that array but never surfaced it to the user; (2) the payload sent `filament: ""`, which some firmwares treat as an invalid-field refusal. The `/drying/start` endpoint now inspects the live `dry_sf_reason` for the target AMS unit and returns a descriptive 409 (e.g. "Plug in the external AMS power adapter to start drying") instead of silently publishing, and backfills an empty `filament` from the first loaded tray's type (defaulting to `PLA`) so the printer never rejects the command for a missing field. Thanks to @MartinNYHC for reporting.
- **Webhook Tokens Leaked into Logs When Debug Logging Enabled (Security)** — Turning on Settings → Support → Debug Logging elevated the `httpx` and `httpcore` loggers to DEBUG, which caused httpx to log the full URL of every outbound HTTP request. For Discord notifications and generic webhook notifications, the URL *is* the secret — the bearer token is embedded in the path — so any user who enabled debug logging (typically to capture logs for a bug report) was writing their Discord webhook token to `bambuddy.log` and then pasting it into GitHub issues or support bundles. `httpx`/`httpcore` are now pinned to `WARNING` regardless of the debug toggle; `paho.mqtt` still honours debug. If you enabled debug logging while notifications were sending, rotate any exposed Discord/webhook URLs — the token is in the path, so the whole URL must be regenerated in the provider's UI.
- **Queue Item Stuck in "Printing" When Start Command is Dropped** ([#967](https://github.com/maziggy/bambuddy/issues/967)) — If the physical printer dropped or ignored the MQTT `project_file` start command (same half-broken-session shape as #887/#936), the queue item was permanently orphaned in the `printing` status at 100% because the scheduler optimistically flipped the DB row to `printing` right after the publish succeeded locally and had no watchdog to revert it. Recovery required manually editing the SQLite `print_queue` table. A new watchdog now captures the printer's pre-dispatch state and polls for up to 45 s after `start_print()` returns; if the printer never transitions, the item is reverted to `pending` so the scheduler picks it up again, and the MQTT session is force-reconnected so the retry lands without a printer reboot. Thanks to @stringham for reporting.
- **Queued Prints Require Printer Reboot to Start** ([#936](https://github.com/maziggy/bambuddy/issues/936)) — On some printers, a queued print would be uploaded via FTP and the `project_file` MQTT command would be sent, but the printer never transitioned out of `FINISH`/`IDLE` and required a power cycle to unstick — after which it often started a previously cancelled print rather than the intended one. Root cause is a half-broken MQTT session (same shape as #887): the printer keeps publishing telemetry so Bambuddy reports it as connected, but our publishes on the command topic never reach the firmware. Existing recovery only triggered via the developer-mode probe path, which skips printers that already have a known `developer_mode` value. The print-dispatch verifier now treats an unacknowledged `project_file` (state unchanged after 15 s) as the same "commands not reaching printer" signal and forces a fresh MQTT session so the next dispatch can land without a printer reboot. The existing dev-mode probe path is refactored to share the same helper.
- **Clear Plate Confirmation Bypassed on Power Cycle** ([#961](https://github.com/maziggy/bambuddy/issues/961)) — With Auto Off enabled and another job queued, the smart plug would cut power when a print finished and immediately re-power when the scheduler saw the queue, at which point the printer booted fresh into `IDLE` and the next job auto-dispatched without the "Clear Plate & Start Next" confirmation. Root cause: the plate-cleared gate lived only in the in-memory `PrinterManager._plate_cleared` set, and the scheduler's idle check treated `IDLE` as always-idle regardless of whether a previous finish had been acknowledged — so the gate was lost across both Bambuddy restarts and the IDLE-on-boot state transition. The gate is now an `awaiting_plate_clear` column on the `printers` table, set by `on_print_complete` when a print finishes or fails, cleared by the `/printers/{id}/clear-plate` endpoint and by the scheduler when it dispatches the next job, and rehydrated from the DB into `PrinterManager` on startup. `_is_printer_idle` now short-circuits to not-idle whenever `require_plate_clear` is on and the printer is awaiting ack, regardless of the currently reported state — so the prompt survives Auto Off cycles, Bambuddy restarts, and the printer booting back into `IDLE`. The clear-plate endpoint no longer requires the printer to currently report `FINISH`/`FAILED` (it accepts the ack whenever the awaiting flag is set), and the Printers page widget prompts based on the flag rather than the reported state. Thanks to @miaopas for reporting.
- **Insecure Temp File Creation in Backup Export** — The manual backup download endpoint used `tempfile.mktemp()`, which is vulnerable to a symlink race condition (CWE-377). Replaced with `tempfile.mkstemp()` which atomically creates the file, eliminating the TOCTOU window.
- **Spoolman Iframe Blocked After 0.2.3b4 Security Headers** — The Spoolman page (Inventory → Spoolman iframe) failed to load when Spoolman was served from the same host as Bambuddy via a reverse proxy. The security-headers middleware added in 0.2.3b4 set `X-Frame-Options: DENY` on every response, which blocked even same-origin iframing. Relaxed to `SAMEORIGIN` so Spoolman (and any other same-origin tool behind the same reverse proxy) can be embedded again, while still preventing cross-origin clickjacking.
- **Large 3MF Print Restart Mid-Job Kept Duplicate Archive With Wrong Duration** ([#972](https://github.com/maziggy/bambuddy/issues/972)) — Second wave of #972 reports — a reproducer on a 37.5 MB BambuStudio-pushed print to an A1 surfaced three distinct problems that compounded across a Bambuddy container restart mid-print. (1) *Archive start_time lost*: the print-start handler only deduped existing `printing` archives by filename and marked them cancelled once older than 4 h — so a 13 h print that had a restart 10 h in got its archive cancelled, a brand-new archive created with `started_at = now()`, and the final duration displayed as ~1.5 h for a job that actually ran 13 h. Fixed by persisting the MQTT-provided `subtask_id` on every archive row (new `subtask_id` column, auto-added via the existing inline migration runner) and matching on that id first, regardless of age. Same id means same print; the row is resumed in place with its original `started_at`. Also revives `Stale`-cancelled rows from the legacy path if an earlier Bambuddy version already ran the old cancel-then-recreate logic. (2) *3MF search retried non-existent paths for ~48 min*: the path order was `/cache/ → /model/ → /data/ → /data/Metadata/ → /`, and every missing path burned the full retry budget (user had `ftp_retry_count = 10` with 30 s delay ⇒ 11 × 30 s × 4 missing paths ≈ 22 min before the real `/` root path was even tried). BambuStudio/OrcaSlicer actually push to `/` on A1-family printers, so the "most likely" path was tested last. Fixed by reordering to try `/` first, and by raising a new `FileNotOnPrinterError` sentinel from `download_to_file` when the FTP response is a 550 (file not found) so `with_ftp_retry`'s `non_retry_exceptions` short-circuits instead of waiting out the full delay ×11 retries against a path that will never have the file. Transient errors (425 "can't open data connection", SSL EOF, connection resets) still retry as before. (3) *Same 36 MB downloaded twice* — the cover-thumbnail endpoint and the archive-metadata handler each opened their own FTP session for the same file during the print, and the second session often hit 425 because the first was still using the printer's single FTP socket. Added a small in-memory `_threemf_path_cache` keyed on (printer_id, normalized filename): whichever flow fetches the 3MF first populates the cache, the other flow reuses the file read-only, and `on_print_complete` evicts the entry + deletes the temp file. Normalization collapses `Broly_X`, `Broly_X.3mf`, `Broly_X.gcode.3mf`, `Broly X`, and case variants to the same slot so both flows agree on the key. Net effect for the reproducer: what took ~48 min with a lost start time now takes seconds and the archive keeps its original row + timestamps. Thanks to @mstko for the reproducer and support bundles.
- **Large 3MF Files Silently Dropped After Print Finish** ([#972](https://github.com/maziggy/bambuddy/issues/972)) — After large prints, the Files tab rows arrived with no thumbnail, no filament breakdown and no cost — the archive row got created as a fallback with no 3MF even when the file was sittable on disk. Two root causes in the 3MF-fetch path. (1) The configured `ftp_timeout` setting (default 30 s, reporter had raised it to 300 s) was only plumbed through as the FTP *socket* timeout; the outer `asyncio.wait_for` wrapping `run_in_executor` was stuck on the hardcoded 60 s default, so the user's 300 s value never applied — every 3MF download was capped at 60 s regardless. (2) `asyncio.wait_for` cannot cancel `run_in_executor` threads: when the 60 s outer timeout fired, the executor thread kept running `ftplib.retrbinary` and frequently completed the download successfully ~30–60 s later — logging `"Successfully downloaded … N bytes"` and caching the working FTP mode — but by then the async wrapper had already returned `False`, so the retry loop kept re-attempting the same path, each attempt truncating the file the zombie thread had just written. After all 4 attempts the wrapper reported `failed after 4 attempts` and the archive was persisted as a fallback (no 3MF, empty `file_path`). The async wrapper now (a) accepts and uses `timeout` at each call site so `ftp_timeout` controls both the asyncio deadline and the socket deadline, and (b) salvages a post-timeout success: when the executor thread has set an explicit completion flag and the file is on disk, the wrapper returns `True` instead of discarding the result. Also fixes a cosmetic `//` prefix in the directory-search download path (`posixpath.join` replaces string concatenation that produced `"//file.3mf"` when the search dir was `"/"`). Thanks to @MartinNYHC for the report and @PurseChicken for the P1S support bundle.
- **SD Card Badge Removed** — After four rounds of fixes the printer-card SD status badge still flipped red on H2D when unrelated activity happened on the network (e.g. powering on an A1 caused every H2D to go red simultaneously). The underlying problem is that Bambu firmware SD-state signaling is not reliably derivable from MQTT: the legacy top-level `sdcard` field is only sent on some pushes with inconsistent typing, and `home_flag` bits 8-9 are cleared on heartbeat pushes even when a card is inserted, with no reliable way to distinguish heartbeats from full status reports. The badge has been removed entirely from the Printers page card and the Printer Info modal. Underlying `state.sdcard` parsing is retained (simplified to a plain truthy read of the `sdcard` field only, no more `home_flag` derivation, no heartbeat latches) because the firmware-update precondition check still needs to know whether a card is inserted before starting an update. Thanks to @MartinNYHC for the extensive reporting across all four rounds. Previously, this entry described the H2D badge flap and its three attempted fixes — kept here for history: The original bug toggled between "inserted" (green) and "not inserted" (red) every few seconds on H2D. Root cause: the MQTT parser used a strict identity check (`data["sdcard"] is True`) on the top-level `sdcard` field, but real firmware ships that field inconsistently — bool on some models, int `1`, or a string enum like `"HAS_SDCARD_NORMAL"` on others — so any message carrying a non-bool value flipped the state to `False`. Fixed by deriving the badge from `home_flag` bits 8–9 (`HAS_SDCARD_NORMAL` / `HAS_SDCARD_ABNORMAL`) when present — the canonical firmware source, same as door and store-to-SD parsing — and falling back to a truthy check on the top-level field for firmwares that only send that. Follow-up: the badge was still flapping because Bambu firmwares send partial MQTT pushes that carry the legacy `sdcard` field alone (without `home_flag`), and the fallback was re-engaging on every such push. The parser now latches `home_flag` as the canonical source for the session once seen, so partial pushes carrying only `sdcard` can no longer flip the badge; the latch resets on reconnect so a firmware change still re-learns. Second follow-up: on H2D the badge still showed red on initial Printers-page navigation and flipped to green on reload, because H2D also sends heartbeat-style `home_flag` pushes where bits 8–9 are clear even when a card is inserted. Downgrades from true→false now require three consecutive clear reads (upgrades false→true still apply immediately), so a single heartbeat no longer turns the badge red. Third follow-up: the three-strike counter still lost the race on idle printers — once an A1 or other printer connecting nearby triggered a burst of MQTT activity, idle H2Ds could accumulate ≥3 heartbeat pushes before the next full status report and all flip to red simultaneously. Reworked the derivation: the legacy top-level `sdcard` field is now authoritative when present (truthy check covers bool/int/string firmware variants), `home_flag` bits 8–9 are only consulted on full `push_status` reports (identified by the presence of multiple state markers like `gcode_state`, `mc_percent`, `nozzle_temper`, `print_type`, `stg_cur`, or `ams`), and bare heartbeat pushes carrying `home_flag` alone no longer affect SD state at all. Thanks to @MartinNYHC for reporting.
- **Archive Reprints Show Wrong Duration in Third-Party MQTT Monitors** ([#1011](https://github.com/maziggy/bambuddy/issues/1011)) — Re-printing a file from Bambuddy's archive caused external MQTT observers like OctoEverywhere to report wildly wrong durations: a 40 min job first reprint would show ~1 h 40 min, and a second reprint of the same file would compound further (~4 h for a ~45 min print), with the excess roughly matching the wall-clock gap since the previous archive replay. The same file printed via BambuStudio → Bambuddy proxy → printer reported correct durations every time. Root cause: the archive-reprint path built the MQTT `project_file` command with hardcoded `project_id="0"`, `subtask_id="0"`, `task_id="0"`, and `md5=""`, while BambuStudio mints unique identity fields per submission. The printer uses those IDs to key per-job state (including `gcode_start_time`), so when every reprint arrived under the same `task_id=0`, the printer reused the prior job's start timestamp instead of emitting a fresh state-transition event — third-party tools that derive duration from that timestamp latched onto a stale value, and successive replays compounded the error. `bambu_mqtt.start_print()` now generates a per-submission millisecond timestamp for `project_id`/`subtask_id`/`task_id` and a unique `md5` derived from the filename + timestamp, matching BambuStudio's per-submission-unique-ID behavior. Covers both archive reprints and direct prints from the Library. Thanks to @PurseChicken for the controlled A/B reproducer (Studio vs archive reprint) that pinpointed the divergence to the print-start command payload.
- **CSP Blocked Sidebar Iframes, Service-Worker Registration, and Google Fonts** — The strict `Content-Security-Policy` header added in 0.2.3b4 broke three things at once: (1) custom sidebar links pointing at external HTTPS URLs (e.g. a Grafana/telemetry dashboard) rendered in `ExternalLinkPage` were blocked because no `frame-src` was declared and iframes fell back to `default-src 'self'`; (2) the inline service-worker registration `<script>` at the bottom of `index.html` was blocked by `script-src 'self'`, silently preventing the PWA service worker from installing; (3) the `@import` of Google Fonts' Inter from `index.css` was blocked by `style-src` and `font-src`. Fixed by adding `frame-src 'self' https:` for user-configured HTTPS iframe targets, moving the inline SW-registration script into `/sw-register.js` so `script-src 'self'` covers it without needing `'unsafe-inline'` or per-build hashes, and allowing `https://fonts.googleapis.com` in `style-src` and `https://fonts.gstatic.com` in `font-src`. `frame-ancestors 'none'` is preserved so Bambuddy itself still cannot be framed cross-origin.


## [0.2.3b3] - 2026-04-12

### Improved
- **AMS Drying Support for P2S** — Remote AMS drying and queue auto-drying now work on P2S printers with firmware 01.02.00.00 or later. Previously P2S was hard-blocked from the drying feature.

### New Features
- **Scheduled Local Backups** ([#884](https://github.com/maziggy/bambuddy/issues/884)) — Settings → Backup now includes a "Scheduled Backups" card that automatically creates complete backup snapshots (database + all data directories) on an hourly, daily, or weekly schedule with configurable time-of-day and retention count. Backups are written as ZIP files to a configurable output directory (defaults to `DATA_DIR/backups/`), which Docker users can mount as a volume to their NAS or external storage. Each backup in the list can be downloaded, restored directly from the UI, or deleted individually. The manual backup download endpoint has also been optimized to stream directly from disk instead of loading the entire ZIP into memory, significantly reducing download wait times for large backups. Works with both SQLite and PostgreSQL installs. Fully localized across all 7 UI languages.
- **SpoolBuddy Device Management Tab** — Settings → SpoolBuddy now lists every registered SpoolBuddy device with live connection status, system details (firmware, IP, CPU temperature, memory, disk, OS, daemon and system uptime), hardware health flags (NFC / scale OK), and an Unregister button gated by a confirm modal. Previously, when a daemon crash caused SpoolBuddy to register itself twice, the kiosk UI silently used only the first device and there was no UI path to delete the orphaned duplicate — administrators had to delete the row directly in the database. A new `DELETE /spoolbuddy/devices/{device_id}` endpoint (gated by `inventory:delete`) handles the removal and broadcasts a `spoolbuddy_unregistered` websocket event so other tabs refresh immediately. A yellow warning banner appears when more than one device is registered to flag likely crash-duplicates. If an online device is accidentally unregistered, it will re-register itself on its next heartbeat. The Settings tab header also shows a device-count badge and a green/gray bullet indicating whether at least one registered device is online. Fully localized in English, German, and Japanese.
- **Print Files Directly from Project View** ([#930](https://github.com/maziggy/bambuddy/issues/930)) — The project detail page now lists the printable files from every linked library folder inline, with Play (Print Now) and CalendarPlus (Add to Queue) action buttons on each sliced file (`.gcode` and `.gcode.3mf`). No more round-tripping through File Manager to reprint project files. Prints triggered from the project view are automatically associated with the originating project, so the resulting archive shows up in that project's history without any manual assignment. Backend adds a `project_id` query parameter to `GET /library/files` that returns all files across linked folders in a single query (replacing the prior one-request-per-folder pattern) and validates `project_id` on both the direct-print and queue paths so a stale ID yields a 404 instead of a FK-constraint 500. Fully localized across all 7 UI languages. Thanks to @legend813 for the contribution.
- **Printers Page Search and Filters** ([#852](https://github.com/maziggy/bambuddy/issues/852)) — The Printers page now has a live search bar and two filter dropdowns (status and location) to make finding specific printers in large setups easier, especially on mobile where Ctrl+F is impractical. Search matches printer name, model, location, and serial number (case-insensitive, whitespace-trimmed) and has a clear button. The status filter covers All / Printing / Paused / Idle / Finished / Error / Offline and is reactive to WebSocket status updates via a React Query cache subscription — so a print finishing while "Printing" is selected immediately removes the printer from the filtered list. The location filter is only shown when at least one printer has a location configured. All three filters are combinable; the controls are hidden when no printers are configured yet; and an empty-state message appears when no printer matches the current search/filters. Fully localized across all 7 UI languages. Thanks to @legend813 for the contribution.
- **LDAP Default Fallback Group** — Settings → Authentication → LDAP → Advanced now has a "Default group" selector. When an LDAP user authenticates but is not listed in any mapped LDAP group, they are automatically assigned to this fallback group instead of being left without permissions. Previously such users could log in successfully but landed on empty pages because every permission check failed. Leave the setting empty to preserve the old behavior. A warning is logged each time the fallback is applied so administrators can spot missing group assignments.

### Changed
- **SpoolBuddy Auto-Wake on NFC/Scale** ([#945](https://github.com/maziggy/bambuddy/issues/945)) — The SpoolBuddy kiosk display now wakes automatically when a spool is placed on the scale or an NFC tag is scanned, without requiring a touch first. The daemon discovers the Wayland session from the shared runtime directory and toggles HDMI power via `wlopm`, coexisting with `swayidle` which continues to handle touch-based wake independently. Gracefully degrades when `wlopm` is not installed or no Wayland session is available. Thanks to @TravisWilder for the suggestion.
- **SpoolBuddy Kiosk LCD Now Powers Off on Idle** ([#937](https://github.com/maziggy/bambuddy/issues/937)) — The SpoolBuddy kiosk's "screen blank timeout" setting previously only painted a black CSS overlay over the browser window; the HDMI panel's backlight stayed on indefinitely, wasting power and letting OLED/LED panels burn in. The blanking path is now moved down to the OS layer: the install script installs `swayidle` and `wlopm`, and labwc's autostart launches a new watchdog (`spoolbuddy/install/spoolbuddy-idle.sh`) that queries the backend once on boot for the device's `display_blank_timeout` and hands it to `swayidle`, which powers HDMI off via `wlopm --off HDMI-A-1` after the configured idle period and powers it back on via `wlopm --on` when labwc delivers any input event (touch, keypress). The redundant CSS overlay and its pointer/keyboard listeners have been removed from `SpoolBuddyLayout` — one source of truth now. Screen blanking is opt-in: `display_blank_timeout=0` (the default) skips launching swayidle entirely and the display stays on forever, preserving current behavior for users who didn't pick a timeout. The default for users who newly enable blanking is 300 seconds. Changes made to the timeout in SpoolBuddy Settings → Display take effect on the next kiosk restart — tap Quick Menu → Restart Browser to apply without a full reboot. A new `GET /api/v1/spoolbuddy/devices/{device_id}/display` endpoint (gated on `inventory:update`, same as the existing `PUT` and heartbeat endpoints) is what the kiosk-side watchdog reads, so no new permissions are required on the device's API key. The watchdog also writes a full startup trace (env vars, resolved timeout, the exact `swayidle` command it execs) to `~/.cache/spoolbuddy-idle.log` so any future breakage on a different kiosk setup is trivially diagnosable, and auto-detects `WAYLAND_DISPLAY` from `XDG_RUNTIME_DIR` with a short retry loop in case labwc hasn't finished exporting its env by the time autostart runs. Thanks to @TravisWilder for reporting.

### Fixed
- **H2C Nozzle Rack Slot Numbering Off When Slot 1's Nozzle Is Mounted** ([#943](https://github.com/maziggy/bambuddy/issues/943)) — The H2C nozzle rack card on the Printers page rendered every rack slot shifted by one position whenever the lowest-numbered slot (rack ID 16, displayed as "slot 1") had its nozzle currently picked up into a hotend. In that state the printer firmware omits the mounted slot's ID from `device.nozzle.info` entirely instead of sending an empty placeholder, so the rack arrived with 5 entries (IDs 17..21) plus the 2 L/R hotends. The frontend was computing its rack base ID via `min(present_ids)`, which then became 17 instead of the fixed 16, and every remaining nozzle was rendered one position to the left — the nozzle physically in slot 2 appeared as "slot 1", slot 3 appeared as "slot 2", and so on, with the single empty placeholder falling off the right end as a phantom "slot 6" that should have been the actual empty "slot 1". The rack base is now hardcoded to 16 to match the fixed H2C rack ID layout (already encoded in the `test_h2c_nozzle_rack_populated_with_8_entries` backend test), so the empty slot stays anchored to its physical position regardless of which nozzle is currently in use. A frontend regression test exercises exactly this case (ID 16 missing, remaining slots in order) and asserts the rendered slot row reads `[—, 0.2, 0.6, 0.8, 1.0, 1.2]`. Thanks to @netscout2001 for reporting.
- **Energy Snapshot Capture Crashes on PostgreSQL** — With an external PostgreSQL database configured, the hourly smart-plug energy snapshot loop (introduced with the #941 fix) logged `asyncpg.DataError: invalid input for query argument $2: ... can't subtract offset-naive and offset-aware datetimes` every hour and failed to persist any snapshots, so date-filtered energy statistics in total-consumption mode stayed empty on Postgres installs. The engine already had a `before_cursor_execute` hook that strips `tzinfo` from bound datetime parameters before they reach asyncpg (the `smart_plug_energy_snapshots.recorded_at` column is `TIMESTAMP WITHOUT TIME ZONE` to match the rest of the schema), but the hook only stripped datetimes one level deep — when SQLAlchemy's `insertmanyvalues` feature batched multiple snapshot rows into a single `INSERT ... SELECT FROM (VALUES ...)` statement, parameters arrived as nested containers (lists of tuples, or a list inside an outer container) and the inner datetimes slipped through untouched. The hook now recursively walks any nesting of dict/list/tuple and strips `tzinfo` at any depth, so every parameter shape SQLAlchemy may use is handled. SQLite installs were never affected (SQLite ignores tzinfo entirely).
- **Wrong Filament Color Name Shown on Printer Tab AMS Popup** ([#857](https://github.com/maziggy/bambuddy/issues/857)) — PLA Translucent Cherry Pink (and other colors outside a small hand-maintained list) appeared as "Scarlet Red" on the Printer tab AMS slot popup, and was also auto-provisioned into the inventory under the wrong name on the first RFID read. Root cause: both the backend spool auto-provisioner and the frontend AMS popup resolved color names by looking up the Bambu `tray_id_name` code (e.g. `A17-R1`) in a hardcoded table, and when the exact code wasn't listed they fell back to a suffix-only lookup (`R1 → Scarlet Red`). The suffix half of that code is **not** globally unique across material families — `A17-R1` is PLA Translucent Cherry Pink, while `A01-R1` is PLA Matte Scarlet Red — so the fallback was structurally guaranteed to produce wrong names for any color the hand-maintained list didn't happen to cover. The resolver has been rewritten to use the existing `color_catalog` table (seeded from `catalog_defaults.py` plus the FilamentColors.xyz sync) as the single source of truth. Backend lookup is now by hex color against the catalog; the frontend fetches a compact `{hex: name}` map once per session via a new `GET /api/inventory/colors/map` endpoint (available to any authenticated user, not gated on `inventory:read`), stores it in a `ColorCatalogProvider` context, and uses it for all `getColorName()` calls. The hardcoded tables in `backend/app/core/bambu_colors.py`, `frontend/src/utils/colors.ts`, and `frontend/src/pages/PrintersPage.tsx` have been removed entirely. Existing spools that were auto-created with a wrong name before this fix need to be renamed manually — the fix only affects new auto-provisioning and live display. Thanks to @lightmaster for reporting.
- **LDAP Auto-Provisioning Fails on Upgraded SQLite Installs** ([#794](https://github.com/maziggy/bambuddy/issues/794)) — First LDAP login on an upgraded SQLite install hit `sqlite3.IntegrityError: NOT NULL constraint failed: users.password_hash` and fell through to a 500 response, because the `users` table on disk had been created before LDAP support landed with `password_hash VARCHAR(255) NOT NULL`. The model was already `nullable=True` and the migration to drop the constraint existed, but only ran on PostgreSQL — SQLite was skipped entirely because it has no `ALTER COLUMN ... DROP NOT NULL`. The migration now patches `sqlite_master` directly via `PRAGMA writable_schema` and bumps `PRAGMA schema_version` so the current connection reloads the table definition without requiring a restart. Fresh installs were never affected (they go through `Base.metadata.create_all` which uses the current nullable model). Thanks to @DylanBrass for reporting.
- **Energy Statistics Empty for Week/Month/Day in Total Consumption Mode** ([#941](https://github.com/maziggy/bambuddy/issues/941)) — With "Total consumption" selected as the energy tracking mode, the Statistics page showed the correct kWh total for All Time but zero for every time-filtered range (Today, This Week, This Month, …). The backend fell back to summing per-print archive energy whenever a date filter was active, but in total-consumption mode the per-print column was often empty for two reasons: (1) the starting-kWh value was held in an in-memory dict (`_print_energy_start`) that was lost on any backend restart mid-print, so prints that spanned a restart never got an energy delta computed; (2) historical prints from before a smart plug was added had no value at all. The fix replaces the in-memory dict with a persisted `energy_start_kwh` column on the archive row, and adds an hourly snapshot loop (`smart_plug_energy_snapshots` table) that captures each plug's lifetime counter. The `/archives/stats` endpoint now computes date-range totals via per-plug `(last-in-range − baseline)` deltas from those snapshots, clamping counter resets to zero. A warming-up flag is returned (and rendered as a tooltip next to the Energy stats on StatsPage) when the query runs on incomplete snapshot history — e.g. right after upgrade, before the hourly loop has built up a baseline before the selected range — so the "low" values during the first hours after upgrading are explained in-product rather than misread as a bug. Fully localized across all 7 UI languages. Per-print energy tracking is now restart-resilient in all modes as a side-effect. Thanks to Mike (@TheMadMike23) for reporting.
- **Virtual Printer "Synchronizing device information" Times Out in Orca** ([#927](https://github.com/maziggy/bambuddy/issues/927)) — OrcaSlicer's "Send job" flow sat on "Synchronizing device information…" until it gave up, even though the FTP upload itself worked when the user clicked "Send job anyway". The virtual printer's MQTT server gated all incoming command handling on `f"device/{self.serial}/request" in topic` — if the slicer's cached serial for the VP didn't exactly equal the VP's computed `self.serial` (which depends on model prefix + per-VP `serial_suffix`), every `get_version`, `pushall`, and `project_file` publish was silently dropped. Nothing was logged past the initial "MQTT publish to …" line, so the slicer never received a `push_status` or `get_version` response on its subscribed `device/{serial}/report` topic and hit its sync timeout. Status pushes, version responses, and project_file acknowledgments were *also* being published on `device/{self.serial}/report`, so even when the incoming check happened to pass, replies targeted a topic the slicer wasn't listening on if its serial had drifted. Both directions are now serial-adaptive: the handler accepts any authenticated publish on a `device/*/request` topic, extracts the serial the slicer is actually using from the topic, stores it per-connection, and uses it for every outgoing status report, version response, print acknowledgment, and periodic push so responses always land on the topic the slicer subscribed to. The client's serial is cleared when the connection closes and when the server stops. Regression tests cover the mismatched-serial publish path, the non-request-topic rejection path, the pushall→status_report routing, and the client-serial lifecycle.
- **External Sidebar Link Icon Not Showing** ([#878](https://github.com/maziggy/bambuddy/issues/878)) — Custom icons uploaded for external sidebar links rendered correctly in the edit dialog but were missing from the sidebar itself, and opening the icon URL directly returned `{"detail":"Valid camera stream token required..."}`. The sidebar `<img>` tag in `Layout.tsx` used a raw `/api/v1/external-links/{id}/icon` URL, but that endpoint is protected by a query-string stream token (the same mechanism used for camera streams and archive thumbnails, because `<img>` tags cannot send Authorization headers). The edit dialog already routed through `api.getExternalLinkIconUrl()`, which wraps the URL via `withStreamToken()`; the sidebar now does the same, so icons appear when auth is enabled.
- **Shortest Job First Toggle Disappears After Clicking** ([#879](https://github.com/maziggy/bambuddy/issues/879)) — The SJF toggle badge on the queue page was rendered inside the Pending Queue section header, which is only shown when there is at least one pending item and the list view is active. Clicking the toggle often coincided with the scheduler starting the only pending print, at which point the Pending section unmounted and the toggle vanished along with it — making it look like the button had disappeared after clicking. The toggle has been moved to the top of the queue page, next to the list/timeline view switcher, so it stays reachable regardless of pending-item count, active filters, or the selected view mode.
- **SpoolBuddy Update Fails in Docker with "no user exists for uid 1000/1001"** — The SpoolBuddy remote-update flow shelled out to the OpenSSH `ssh-keygen` and `ssh` binaries for keypair creation and command execution. Both binaries call `getpwuid(getuid())` at startup and abort with `No user exists for uid <N>` when the container runs under an arbitrary PUID that is not listed in `/etc/passwd` (the stock `python:3.13-slim` image only has an entry for root, so running with `user: "1000:1000"`, `"1001:1001"`, or any non-root user tripped the same error). The entire SpoolBuddy update path is now subprocess-free: keypairs are generated in-process via the `cryptography` library (already a dependency), SSH commands run through the pure-Python `asyncssh` client, and git-branch detection reads `.git/HEAD` directly instead of shelling out to `git`. asyncssh also calls `getpass.getuser()` for local `~/.ssh/config` host matching, which hit the same passwd lookup failure; the Docker image now sets `LOGNAME=bambuddy`, `USER=bambuddy`, and `HOME=/app` so `getpass.getuser()` resolves via env vars before touching the passwd database, and `asyncssh.connect()` is called with `config=[]` so it does not attempt to load `~/.ssh/config` at all. Branch detection also now looks for `.git/HEAD` in the *application root* rather than `settings.base_dir` — in Docker the data directory is a separate volume (`DATA_DIR=/app/data`) that never contains `.git`. Finally, the Docker build now bakes `.git/HEAD` into the image (`.dockerignore` allows this single 20-byte file through the context filter) so the production image knows which branch it was built from; previously the `.git` directory was excluded from the build context entirely, leaving the container with no git metadata and causing the SpoolBuddy update flow to always pull `main` on the remote device regardless of which branch Bambuddy itself was built from. Native installs behave identically — they already worked because the running user was always in `/etc/passwd` and `.git/HEAD` was readable from the project root. Regression tests assert that neither keypair creation nor command execution spawns any subprocess, and that branch detection reads from the application root even when a decoy `.git` sits inside the data dir.
- **Camera Stream "6 of 5" Reconnect Counter + ffmpeg Log Flood** ([#925](https://github.com/maziggy/bambuddy/issues/925)) — Two bugs surfaced while investigating camera reconnect behaviour. First, the camera page briefly displayed "Reconnecting attempt 6 of 5" before giving up, because the attempt counter could be incremented to the maximum while the reconnect banner was still rendering. The displayed value is now clamped to the configured maximum. Second, every failed ffmpeg spawn logged the full ~20-line ffmpeg version/configuration banner, producing hundreds of lines of noise per failed camera click (one reported click produced 555 log lines across 30 retries). A new stderr summarizer strips the ffmpeg banner before logging so only the actual error lines remain. The underlying "camera service stops accepting new connections after prolonged uptime" behaviour in the X1C firmware is still under investigation.
- **LDAP POSIX Primary Group Ignored** — LDAP authentication only looked at groups that listed the user explicitly via `memberUid` (supplementary group membership). A user's POSIX primary group — referenced by the `gidNumber` attribute on the user object and matching the `gidNumber` on a `posixGroup` — was ignored entirely, so users whose role came from their primary group landed without the expected permissions. The authenticator now also searches for `posixGroup` entries whose `gidNumber` matches the user's primary `gidNumber`, and dedupes DNs case-insensitively before resolving the group mapping (LDAP DNs are case-insensitive by spec).
- **Support Bundle Leaks Virtual Printer IP Address** — The debug support bundle included the `virtual_printer_remote_interface_ip` setting value unmasked in `support-info.json`. The setting key didn't match any of the existing sensitive-key filters, so the raw IP address was included in the bundle. Added `_ip` to the sensitive key filter so IP address settings are excluded from support bundles. Log file content was already covered by the existing IPv4 regex redaction.
- **"Build Plate Cleared" Button Unclickable After Second Print** ([#912](https://github.com/maziggy/bambuddy/issues/912)) — After completing the first queued print and confirming the plate was cleared, the "Build plate cleared — ready for next print" button became unresponsive after the second print finished. The React Query mutation's `isSuccess` state persisted from the first plate-clear confirmation, causing the component to render the static "Plate Ready" confirmation instead of the clickable button. The mutation state is now reset when the printer leaves the FINISH/FAILED state, so the button works correctly on every print cycle.
- **Spoolman Location Not Cleared When Spool Removed from AMS** ([#921](https://github.com/maziggy/bambuddy/issues/921)) — When Spoolman auto-sync was enabled and a spool was removed from an AMS slot, its location in Spoolman was never cleared, causing "double-booked" slots where multiple spools shared the same location. The auto-sync callback set locations for newly inserted spools but skipped the cleanup step that clears stale locations. The location clearing logic now runs after every auto-sync cycle. Also fixed the single-printer manual sync endpoint which didn't track synced spool IDs, risking incorrect location clearing for location-matched (non-RFID) spools.

## [0.2.3b2] - 2026-04-08

### New Features
- **Optional PostgreSQL Database Support** — Bambuddy can now use an external PostgreSQL database instead of the built-in SQLite. Set the `DATABASE_URL` environment variable (e.g., `postgresql+asyncpg://user:pass@host:5432/bambuddy`) to connect to Postgres. SQLite remains the default when no `DATABASE_URL` is set. All features work with both backends including full-text archive search (FTS5 on SQLite, tsvector+GIN on PostgreSQL), backup/restore (file copy vs pg_dump/pg_restore), health diagnostics, and cross-database restore (import a SQLite backup into PostgreSQL with automatic type conversion and FK handling).
- **Shortest Job First Queue Scheduling** ([#879](https://github.com/maziggy/bambuddy/issues/879)) — New SJF toggle badge on the queue page header. When enabled, the scheduler starts shorter print jobs before longer ones instead of FIFO order. A starvation guard ensures long jobs that get skipped once are protected from being skipped again — they move to the front of the queue on the next cycle. The queue display automatically reorders to show the scheduler's actual execution order. Print duration is cached on queue items at creation time from the 3MF metadata.
- **Auto-Print G-code Injection** ([#422](https://github.com/maziggy/bambuddy/issues/422)) — Configure custom start and end G-code snippets per printer model in Settings (Workflow tab) for bed-clearing systems like Farmloop, SwapMod, AutoClear, and Printflow 3D. When adding a print to the queue, enable "Inject G-code" to have the scheduler inject the configured snippets into the 3MF before uploading to the printer. The original file is never modified — injection creates a temporary copy for upload only.
- **External Folder Subfolder Preservation** ([#890](https://github.com/maziggy/bambuddy/issues/890)) — Scanning an external folder now mirrors the real directory structure into the file manager folder tree instead of flattening all files into the root. Subdirectories are created as child LibraryFolders with correct parent/child hierarchy, and files are assigned to their matching subfolder. Hidden directories are skipped when "Show hidden files" is disabled. Subfolders that are deleted from disk are automatically cleaned up on the next scan. Created subfolders inherit the parent's read-only and show-hidden settings.
- **LDAP Authentication** ([#794](https://github.com/maziggy/bambuddy/issues/794)) — Users can now authenticate against an LDAP/Active Directory server. Configure the LDAP server URL, bind DN, search base, and user filter in Settings > Authentication > LDAP. Supports StartTLS, LDAPS (SSL), and plaintext connections. LDAP groups can be mapped to BamBuddy groups (Administrators, Operators, Viewers) for automatic role assignment. Auto-provisioning creates BamBuddy accounts on first LDAP login when enabled. Local admin accounts remain as fallback when the LDAP server is unreachable. Password management features (change password, forgot password, admin reset) are automatically disabled for LDAP users.
- **SpoolBuddy Quick Menu** ([#893](https://github.com/maziggy/bambuddy/issues/893)) — Swipe down from the top of the SpoolBuddy display to open a quick-access control panel. Toggle printer power via smart plugs directly from the display, and manage the SpoolBuddy system with restart daemon, restart browser, reboot, and shutdown controls. All destructive actions require confirmation. The menu shows real-time smart plug state (ON/OFF) for each printer that has a linked power plug.

### Improved
- **Database Engine Info on System Page** — The System Information page now shows the active database engine (SQLite or PostgreSQL) and its version in the Database section, making it easy to verify which backend is in use.
- **Plate Number in Printer View** ([#881](https://github.com/maziggy/bambuddy/issues/881)) — Printer cards and the stream overlay now show the plate number alongside the filename when printing plate 2+ of a multi-plate 3MF file (e.g. "MyModel — Plate 3"). Single-plate prints are unchanged.
- **Printer Name in Queue for Model-Based Jobs** ([#881](https://github.com/maziggy/bambuddy/issues/881)) — Queue items assigned to a printer type ("Any P1S") now show the actual printer name once the scheduler assigns a specific printer, instead of continuing to display the generic model target while printing or in history.
- **AMS Drying Support for H2S** ([#886](https://github.com/maziggy/bambuddy/issues/886)) — Remote AMS drying and queue auto-drying now work on H2S printers with firmware 01.02.00.00 or later.
- **REST Smart Plug: Separate Power/Energy URLs and Unit Multipliers** ([#472](https://github.com/maziggy/bambuddy/issues/472)) — REST/Webhook smart plugs can now use individual URLs for power and energy data instead of requiring all values in a single status response. Each value falls back to the shared Status URL when no separate URL is configured, so existing setups work without changes. Added power and energy multipliers for unit conversion (e.g., set energy multiplier to `0.001` to convert Wh to kWh). Useful for platforms like ioBroker that expose each data point as a separate API endpoint.

### Security
- **Path Traversal in File Upload Endpoints** — Archive upload endpoints (`/upload`, `/upload-bulk`, `/{id}/source`, `/source-by-name`, `/{id}/f3d`, `/{id}/timelapse`) used the client-supplied filename directly in file paths without stripping directory components. An authenticated attacker could write files outside the intended directory via directory traversal (e.g. `../../evil.3mf`). All upload endpoints now sanitize filenames by extracting only the basename before constructing paths. Reported responsibly by Sacha Vaudey via security@bambuddy.cool.
- **Unauthenticated Bug Report Endpoints** — The bug report endpoints (`/start-logging`, `/stop-logging`, `/submit`) had no authentication, allowing anyone on the network to enable debug logging, retrieve system logs, and trigger bug report submissions with system diagnostics when authentication was enabled. All three endpoints now require authentication — `start-logging` requires `settings:update` permission, `stop-logging` and `submit` require `settings:read`. Endpoints remain open when authentication is disabled (the default). Reported responsibly by Sacha Vaudey via security@bambuddy.cool.
- **API Key Empty Printer List Grants Full Access** — An API key with an empty `printer_ids` list (`[]`) was treated identically to `null` (global access to all printers), granting full printer access instead of no access. Now `null` means global access (admin key) and `[]` means no printer access. Existing API keys with empty lists are automatically migrated to `null` on startup. Also fixed the webhook queue endpoint which used a falsy check that would bypass the filter for empty lists. Reported responsibly by Sacha Vaudey via security@bambuddy.cool.
- **Missing HTTP Security Headers** — API responses did not include standard security headers. Added a middleware that sets `X-Content-Type-Options: nosniff` (prevents MIME-sniffing), `X-Frame-Options: DENY` (prevents clickjacking via iframe embedding), and `Referrer-Policy: strict-origin-when-cross-origin` (limits URL leakage to external services) on every response. `Content-Security-Policy` was omitted because the React SPA uses inline styles extensively and a permissive CSP would provide no meaningful protection. `Strict-Transport-Security` was omitted because Bambuddy is a LAN application commonly accessed over HTTP — HSTS would lock users out. Reported responsibly by Sacha Vaudey via security@bambuddy.cool.
- **Camera Snapshot Temp Files World-Readable** — Camera snapshot and plate detection endpoints created temporary JPEG files in `/tmp` with default 0644 permissions, making them readable by any local user. Switched from `NamedTemporaryFile(delete=False)` to `mkstemp` with explicit 0600 permissions so only the application user can read them. Cleanup was already handled via `finally` blocks. Reported responsibly by Sacha Vaudey via security@bambuddy.cool.

### Fixed
- **Spool Weight Not Updated After Print** ([#839](https://github.com/maziggy/bambuddy/issues/839)) — Filament usage tracking failed silently in several scenarios: (1) when FTP download failed and a fallback archive was created without a 3MF file, the primary tracking path was skipped entirely — now falls back to matching the 3MF from the library or a previous archive of the same file; (2) external/VT tray spools were never tracked by the AMS remain% fallback because it only iterated AMS unit trays — now captures and tracks VT tray remain% deltas; (3) notifications showed "Unknown" for time and filament on fallback archives — now enriches notifications with usage tracker results and captures estimated print time from MQTT at archive creation; (4) when auto-archive was disabled, `archive_id` was None at print completion so the entire 3MF tracking path was skipped — now searches library files and previous archives by filename to find the 3MF even without an archive, and captures the AMS slot-to-tray mapping at print start so it's available at completion regardless of archive state; (5) when auto-archive was disabled but the print was dispatched by BamBuddy (queue/reprint), the on_print_start callback discarded the expected print entry and returned early — the archive was never promoted to `_active_prints`, so at completion `archive_id` and `ams_mapping` were both None, making all tracking paths fail. Now detects expected prints before the auto-archive early-return and falls through to the normal promotion path, also injecting the stored `ams_mapping` into the usage tracker session.
- **File Manager Stale UI After Deleting Folders/Files** — Deleting a folder, file, or bulk-deleting items in the file manager appeared to succeed (toast shown) but the UI didn't update until a page reload. The delete endpoints (`delete_folder`, `delete_file`, `bulk_delete`) relied on FastAPI's dependency cleanup auto-commit which runs after the response is sent — the frontend received the success response, refetched the folder/file list, but the delete hadn't been committed yet. Added explicit `db.commit()` before returning in all three endpoints.
- **Spool Manager Deducts Double the Filament Used** ([#880](https://github.com/maziggy/bambuddy/issues/880)) — After a print completed, the built-in spool manager subtracted twice the actual filament consumption. The printer's MQTT status message contains both updated AMS remain percentages and the `FINISH` state, which triggered two independent deduction paths in the same event loop cycle: the AMS weight sync (absolute SET from remain%) and the usage tracker (additive delta from 3MF data). The AMS weight sync now skips updates while a print session is active, letting the usage tracker handle deductions precisely via 3MF slicer data.
- **Thumbnails Broken After Backend Restart** — Archive and library thumbnails returned 401 Unauthorized after a backend restart because stream tokens are stored in memory and lost on restart. The frontend now detects failed token-protected image loads and automatically refreshes the stream token, so thumbnails recover without a page reload.
- **SpoolBuddy Kiosk Screen Blanks on Boot** — The touchscreen display would blank immediately after the RPi booted, requiring a touch to wake. Added `consoleblank=0` to the kernel cmdline to disable Linux console blanking during the Plymouth-to-labwc transition, and changed the `wlr-randr` anti-blank loop to fire immediately instead of sleeping 60 seconds first.
- **Queue Widget Ignores Plate-Clear Setting** ([#752](https://github.com/maziggy/bambuddy/issues/752)) — The "Clear Plate & Start Next" button on printer cards appeared even when "Require plate-clear confirmation" was disabled in Settings → Queue. The backend correctly auto-dispatched without waiting, but the frontend widget always showed the prompt. The widget now respects the setting and shows a passive queue link instead when plate-clear confirmation is disabled.
- **Ghost Jobs From SQLite Lock on Print Completion** ([#897](https://github.com/maziggy/bambuddy/issues/897)) — When a print finished, the queue status update (`printing` → `completed`) could fail silently if the SQLite database was locked by another writer (e.g. the runtime tracker). The failed commit left the job permanently stuck in `printing` status — a "ghost job" that caused the UI to show false double-assignments when the next job started. The critical queue status commit now retries up to 3 times with backoff on SQLite lock errors (PostgreSQL is unaffected — it uses row-level locking). Additionally, the runtime tracker was holding a single long transaction across all printers; it now commits per-printer to minimize lock hold time.
- **Multi-Plug Automation Only Works for First Plug** ([#903](https://github.com/maziggy/bambuddy/issues/903)) — When multiple smart plugs were assigned to the same printer (e.g. a TUYA printer plug and a particle filter plug via Home Assistant), only the first plug's automation worked. The auto-on at print start, auto-off at print completion, and queue auto-off all queried for a single plug instead of iterating all plugs linked to the printer. All automation paths now control every assigned plug. Also fixed the queue auto-off path which was hardcoded to Tasmota instead of using the correct service for the plug type (HA, MQTT, REST).
- **SpoolBuddy Inventory Not Updating on Spool Changes** — Adding, editing, deleting, archiving, or restoring a spool in the internal inventory did not update SpoolBuddy's frontend views until the next manual refresh or 30-second poll. The spool CRUD endpoints did not emit websocket events, and the SpoolBuddy Dashboard had no polling fallback. All inventory mutation endpoints now broadcast an `inventory_changed` websocket event, and the frontend invalidates the spool cache on receipt — so SpoolBuddy (and all other tabs) reflect changes instantly.
- **AMS Slot Changes Fail Until Reconnect** ([#887](https://github.com/maziggy/bambuddy/issues/887)) — After a keep-alive timeout, paho-mqtt auto-reconnects but the new session can be half-broken: the printer continues sending status updates but silently ignores commands. The developer mode probe detected this (no response, leaving `developer_mode` as `null`), but had no timeout or recovery — one unanswered probe permanently blocked retries. Added a 10-second probe timeout with one retry; after two consecutive unanswered probes, Bambuddy force-closes the socket to trigger a clean reconnect with a fresh session. Additionally, the developer mode probe was firing on every auto-reconnect, which destabilized some firmware MQTT brokers (A1/P1 series) — causing a reconnect → probe → disconnect feedback loop. The probe result is now cached across reconnects and only runs once on the first connection, with a 5-second delay after connect to let the session stabilize.
- **WebSocket Crash on Printers Without `fun` Field** ([#873](https://github.com/maziggy/bambuddy/issues/873)) — Connecting to printers that don't send the MQTT `fun` field (A1, P1 series, X1Plus firmware) caused a repeating `'str' object has no attribute 'get'` crash in the WebSocket handler, showing the printer as offline with missing AMS and SD card info. The developer mode probe introduced in 0.2.3b1 published an MQTT message inside `_update_state()` between overwriting `raw_data` with the full MQTT dict (where `vt_tray` is a raw dict) and restoring the previously normalized list — the `publish()` call released the GIL, letting the event loop read the un-normalized dict and iterate over string keys instead of spool dicts. Fixed by normalizing `vt_tray` dict→list in the MQTT data before assignment, and moving preserved field restoration before the probe. Added defensive normalization in `printer_state_to_dict` as a belt-and-suspenders guard.


## [0.2.3b1] - 2026-04-02

### New Features
- **Queue Timeline View** ([#823](https://github.com/maziggy/bambuddy/issues/823)) — The queue page now has a production schedule view showing when each print is estimated to finish. Events are sorted chronologically and grouped by hour, with cards showing the file name, printer, estimated completion time, and time remaining. Active prints show a live progress bar. Filter by "Show All", "Printing", or "Queued", and navigate between days. Click any event to edit or stop it. Toggle between List and Timeline views with the button group above the queue.
- **Staggered Batch Start for Multi-Printer Jobs** ([#752](https://github.com/maziggy/bambuddy/issues/752)) — When sending a print to multiple printers via the queue, you can now stagger the starts to avoid power spikes from simultaneous bed heating. Enable "Stagger printer starts" in the schedule options to define a group size (how many printers start at once) and interval (minutes between groups). For example, 10 printers with group size 2 and interval 5 min will start in 5 waves over 25 minutes. Default group size and interval are configurable in Settings → Queue. Works with both ASAP and Scheduled timing — ASAP starts the first group immediately, subsequent groups get computed scheduled times. The stagger option is also available in the direct Print dialog when multiple printers are selected — prints are automatically queued with staggered start times, so you can close the browser and walk away.
- **Plate-Clear Confirmation Setting** ([#752](https://github.com/maziggy/bambuddy/issues/752)) — New "Require plate-clear confirmation" toggle in Settings → Queue. When disabled, the scheduler starts queued prints automatically on printers with finished jobs without waiting for per-printer plate confirmation. Useful for farm workflows where plates are verified physically before starting a batch. Default is enabled (existing behavior preserved).
- **Settings Queue Tab** — New dedicated Queue tab in Settings consolidates queue-related settings: staggered start defaults and auto-drying configuration (moved from the Filament tab).
- **Per-User Statistics Filtering** ([#730](https://github.com/maziggy/bambuddy/issues/730)) — Admins can now filter the Statistics page by user. A user dropdown appears in the stats header for users with the new `stats:filter_by_user` permission (Administrators only by default). Filter by a specific user to see their prints, filament usage, and costs, or select "No User (System)" to view prints without user attribution (e.g. slicer-initiated or pre-auth prints). The filter applies to all stats widgets and exports.
- **Bulk Printer Actions** ([#825](https://github.com/maziggy/bambuddy/issues/825)) — Select multiple printer cards and apply bulk actions from a floating toolbar. Toggle selection mode from the header, then click cards to select. Use "Select All", "Select by State" (printing, paused, finished, idle, error, offline), or "Select by Location" to quickly pick printers. Available actions: Stop, Pause, Resume, Clear Notifications, and Clear Bed — each button is smart-enabled based on the selected printers' current states. Confirmation modals for destructive actions (Stop, Pause, Clear Bed). The status summary bar now shows all printer states (printing, paused, finished, idle, error, offline).
- **Prefer Lowest Remaining Filament** ([#805](https://github.com/maziggy/bambuddy/issues/805)) — New optional setting in Settings → Filament that prefers AMS spools with the lowest remaining filament during auto-matching. When multiple spools match the same type and color, the one with the least filament remaining is selected first. Helps consume partial spools before starting new ones. Applies to queue scheduling, print modal, and multi-printer mapping. Unknown remain values (e.g. external spools without sensors) are treated as full. Disabled by default.
- **REST/Webhook Smart Plug Type** ([#472](https://github.com/maziggy/bambuddy/issues/472)) — New "REST" smart plug type for controlling power via generic HTTP APIs. Works with any home automation platform that has an HTTP endpoint (openHAB, ioBroker, FHEM, Node-RED, etc.). Configure separate ON/OFF URLs with custom HTTP methods (GET/POST/PUT/PATCH), request bodies, and headers. Optional status polling via a GET endpoint with JSON path extraction for state, power, and energy monitoring. Fully controllable — supports auto on/off with prints, daily scheduling, sidebar quick-toggle, and power alerts.
- **Configurable Default Print Options** ([#858](https://github.com/maziggy/bambuddy/issues/858)) — Print options (bed levelling, flow calibration, vibration calibration, first layer inspection, timelapse) now have configurable defaults in Settings → Workflow. Set your preferred defaults once and every new print dialog starts with those values. Still overridable per print.
- **Batch Print Quantity** ([#342](https://github.com/maziggy/bambuddy/issues/342)) — Print multiple copies of a file in one step. The print and schedule dialogs now have a quantity field — set it to any number and the system creates that many queue items automatically. When quantity is greater than one, items are grouped into a batch for tracking. In the direct print dialog, the first copy prints immediately while the remaining copies are queued. The queue page shows a batch badge on grouped items. Batch progress and cancellation are available via the API.
- **GitHub Backup: Spool Inventory & Print Archives** ([#870](https://github.com/maziggy/bambuddy/issues/870)) — GitHub backup can now include spool inventory and print archive history as optional toggles alongside the existing K-profiles, cloud profiles, and settings. Spool backup exports all spools with their material, brand, color, weight, cost tracking, RFID tags, and full usage history. Archive backup exports print history metadata (filament, temperatures, times, costs, energy) — no gcode/3MF binary files. Both are off by default and can be enabled independently in Settings → Backup & Restore.

### Improved
- **Standardized Webhook Notification Payloads** ([#871](https://github.com/maziggy/bambuddy/issues/871)) — Custom webhook notifications now include structured event data fields (`event`, `printer`, `filename`, `duration`, etc.) alongside the existing `title`, `message`, `timestamp`, and `source` fields. Previously, only `title` and `message` were sent, requiring automation tools to parse the message text for event details. All event-specific template variables are now included as top-level JSON fields, making it easy for n8n, Node-RED, Home Assistant, and other automation platforms to route and process notifications based on structured data. Slack/Mattermost format is unchanged.
- **Queue Page Visual Refresh** — Compact stats bar replaces the five summary cards (saves vertical space), color-coded left borders on all queue items for instant status scanning, collapsible history section (collapsed by default), and condensed single-line rows for history items showing more prints at a glance.
- **Developer Mode Detection for A1/P1 Printers** — Printers that don't send the `fun` field in MQTT status (A1, P1 series) now have developer mode detected via a probe command. After receiving the first full status update, Bambuddy sends a no-op external slot configure and checks whether the printer accepts or rejects it (`mqtt message verify failed`). Printers that do send the `fun` field (X1C, H2D, etc.) continue to use the existing bit-based detection. Developer mode state is re-checked on every reconnect.

### Fixed
- **Bed Cooled Notification Never Firing** ([#872](https://github.com/maziggy/bambuddy/issues/872)) — Replaced the polling-based bed cooldown monitor with an event-driven approach. The old implementation polled cached bed temperature every 15 seconds for up to 30 minutes after print completion, but some printer firmware (e.g. P2S 01.00.05.00) stops including `bed_temper` in MQTT updates after a print finishes — even in response to pushall requests — causing the cached value to stay frozen at the end-of-print temperature until the monitor timed out. The new approach registers a waiter at print completion and reacts instantly when `bed_temper` data arrives via MQTT, whenever that may be. No timeout, no polling, no stale data — the notification fires as soon as the printer reports the bed is at or below the configured threshold.
- **Filament Color and Subtype Inconsistencies** ([#857](https://github.com/maziggy/bambuddy/issues/857)) — Fixed several filament identification issues: (1) AMS slot popup showed generic color names like "Dark Gray" instead of Bambu-specific names like "Titan Gray" because the fallback skipped the Bambu hex color database. (2) "Silk+" subtype was missing from the known variants list, so the Edit Spool dropdown showed "Silk" instead. Also added "Tough+". (3) Gradient and Dual Color filaments were misclassified — PLA Basic Gradient was detected as "Basic" and PLA Silk Dual Color as "Silk" because the firmware only sends the base material in `tray_sub_brands`. Now detects gradient/multi-color/tri-color variants from the `tray_id_name` color code pattern (M\*/T\* suffixes).
- **External Spool Print Fails on Printers With AMS** ([#854](https://github.com/maziggy/bambuddy/issues/854), [#859](https://github.com/maziggy/bambuddy/issues/859)) — Two related issues with external spool printing: (1) Sending a print to a printer with no AMS units and only an external spool caused "Failed to get AMS mapping table" because the command was sent with `use_ams: true`. Now automatically sets `use_ams: false` when all filament slots map to external spools. (2) Printers with an AMS connected but empty (e.g. X1C with `ams_exist_bits=1, tray_exist_bits=0`) got stuck at heatbed heating or hit the same 07FF_8012 error because the print command used `ams_id: 254` in `ams_mapping2` instead of `255`. The firmware interpreted 254 as a physical AMS tray target instead of external spool. BambuStudio uses `ams_id: 255` (VIRTUAL_TRAY_MAIN_ID) for single-nozzle external spool. Fixed by mapping external spool to `ams_id: 255` on all non-H2D printers. H2D dual-nozzle printers retain 254 (deputy) / 255 (main) distinction.
- **External Folder Scan 500 Error on 3MF Files** ([#846](https://github.com/maziggy/bambuddy/issues/846)) — Scanning an external folder containing .3mf files crashed with "Object of type bytes is not JSON serializable". The parsed 3MF metadata contained raw thumbnail bytes (`_thumbnail_data`) that were stored directly in the database JSON column without cleaning. Also removed a call to the non-existent `parser.extract_thumbnail()` method — thumbnail data is already available in the parsed metadata. Now uses the same `clean_metadata()` pattern as upload and zip extraction.
- **Archives Capped at 50 Items** ([#843](https://github.com/maziggy/bambuddy/issues/843)) — The archives page only showed the 50 most recent prints due to a hardcoded API limit. Users with more than 50 archives could not see or access older entries. Fixed by fetching all archives and adding client-side pagination with configurable page sizes (25, 50, 100, 200, or All). Page size preference is persisted.
- **Filament Usage Not Recorded When Auto-Archive Disabled** — When a printer had "Auto-archive completed prints" turned off, filament consumption was silently lost. The `on_print_complete` callback returned early before reaching the usage tracking code, so neither the internal inventory (AMS remain% deltas) nor Spoolman received usage data. Moved filament tracking to run before the archive check so usage is always recorded regardless of the auto-archive setting.
- **H2D External Spool Uses Wrong Nozzle** ([#836](https://github.com/maziggy/bambuddy/issues/836)) — Prints sent from Bambuddy to dual-nozzle printers (H2D, H2D Pro) with external spools always routed to the wrong nozzle. The old `ams_mapping2` format used a shared `ams_id: 255` with `slot_id: 0/1` to differentiate external slots, but the firmware interpreted slot_id as the nozzle index (0=main/right, 1=deputy/left), routing filament to the opposite nozzle. Already fixed by the #797 `ams_mapping2` format change (per-tray `ams_id` instead of shared unit), but users on older builds still experience this. Printing the same file directly from the slicer worked correctly.
- **SpoolBuddy "Add to Inventory" Failed Silently** — The quick-add button on the SpoolBuddy kiosk did nothing when tapped. The scale weight was sent as a float but the backend requires an integer, causing a Pydantic validation error. The error was silently caught with no user feedback, leaving the confirmation modal stuck open. Fixed by rounding the weight before sending, moving the modal close to a `finally` block, and adding an error toast with the actual API message.
- **SpoolBuddy Dashboard Crash on Null Spool Fields** — Viewing a spool with null `subtype`, `brand`, `rgba`, or `color_name` on the SpoolBuddy dashboard crashed the UI (black screen). The spool prop construction used `displayedSpool?.subtype ?? sbState.matchedSpool!.subtype` — when the field was `null`, the `??` operator fell through to `sbState.matchedSpool` which could also be null, causing a TypeError. Fixed by picking one source object instead of mixing per-field fallbacks. Added a global React error boundary so future crashes show the error instead of a black screen.
- **Plate Thumbnails 401 in Print Modal** — Multi-plate 3MF plate thumbnails in the print modal returned 401 Unauthorized when authentication was enabled. The backend returns bare URL paths for plate thumbnails, but the `PlateSelector` component used them directly in `<img src>` without appending the stream token. Fixed by passing the URL through `withStreamToken()`.
- **Schedule Calendar Picker Opens Off-Screen** — Clicking the calendar icon in the print modal's scheduled mode opened the native date picker at the bottom of the viewport instead of near the date field. The hidden `datetime-local` input used `sr-only` positioning which anchored the picker off-screen. Fixed by positioning the hidden input inside the date field's container.
- **SpoolBuddy Kiosk Display Blanking and Crashes** — The kiosk Chromium flags added in 0.2.2.2 caused display instability: `--js-flags=--max-old-space-size=128` crashed the V8 renderer when heap exceeded 128 MB, `--enable-low-end-device-mode` aggressively killed GPU rendering surfaces, and resetting `CHROMIUM_FLAGS` discarded the Pi's GPU defaults (`--enable-gpu-rasterization`, ANGLE/GLES) creating an unstable mixed CPU/GPU rendering path. Fixed by removing both flags, appending kiosk flags to Pi defaults instead of replacing them, adding a `wlr-randr` keep-alive loop to prevent display blanking, and adding `<screenBlankTimeout>0</screenBlankTimeout>` to the labwc config.
- **Sidebar Bottom Icons Cut Off With Smart Plugs** ([#862](https://github.com/maziggy/bambuddy/issues/862)) — Adding smart plug buttons to the sidebar caused the bottom icon row to overflow and get partially cut off. The footer section could be compressed by the flexbox layout when the navigation area grew. Fixed by preventing the footer from shrinking, allowing the expanded icon row to wrap, and adding scroll overflow to the collapsed sidebar icon stack.
- **AMS History Cleanup Crash Every ~24 Hours** — The periodic cleanup of old AMS sensor history entries failed with "can't compare offset-naive and offset-aware datetimes". The cleanup cutoff used `datetime.now(timezone.utc)` (timezone-aware) but the `recorded_at` column stores naive datetimes via SQLite's `func.now()`. The mismatch caused a TypeError when SQLAlchemy processed the comparison. Fixed by using a naive UTC datetime for the cutoff. The error only appeared once per ~24h because the cleanup runs every 288 recording cycles (288 × 5 min = 24h).
- **SpoolBuddy Status Bar Not Updating on Printer Switch** — The bottom status bar on SpoolBuddy kiosk pages showed stale warnings (e.g. low filament) from the previously selected printer after switching to a different printer via the dropdown or swipe gesture. Two issues: (1) the AMS data cache was a single ref shared across all printers, so switching to a printer whose status hadn't loaded yet fell back to the previous printer's cached AMS data; (2) the Layout's alert useEffect unconditionally cleared alerts to null when the device was online, which could overwrite printer-specific alerts set by child pages. Fixed by keying the AMS cache per printer ID and tracking Layout-owned alerts separately so child page alerts aren't clobbered.

## [0.2.2.2] - 2026-03-27

### New Features
- **Persistent Auto-Off for Smart Plugs** ([#826](https://github.com/maziggy/bambuddy/issues/826)) — Smart plugs now have a "Keep Enabled" toggle under Auto Off settings. When enabled, auto-off stays active between prints instead of requiring manual re-enablement after each print (one-shot). Useful for accessories like BentoBox filters on Home Assistant switches that should always power off when a print completes. Default behavior (one-shot) is unchanged. Requested by @AeroMaestro.
- **Missing Spool Assignment Notification** ([#763](https://github.com/maziggy/bambuddy/issues/763)) — When a print starts and the AMS mapping references tray slots without assigned spools, Bambuddy now shows a warning toast in the frontend and can send push notifications via any configured notification provider. The notification includes the printer name, missing slot labels (e.g. A2, Ext-L), and expected material profile. A new "Missing Spool Assignment" toggle is available under Print Events in notification provider settings (off by default). Fully integrated with i18n (all 7 locales). Contributed by @Keybored02.
- **Mid-Print Spool Reassignment Tracking** ([#763](https://github.com/maziggy/bambuddy/issues/763)) — Usage tracking now correctly handles spool changes during a print. If a spool assignment is changed after a print starts, the system uses the live assignment for filament deduction; otherwise it falls back to the snapshot taken at print start. This ensures accurate filament tracking even when swapping spools mid-print. Contributed by @Keybored02.
- **Auto-Link Untagged Inventory Spools on AMS Insert** ([#538](https://github.com/maziggy/bambuddy/issues/538)) — When a Bambu Lab spool is inserted into the AMS and no existing tag match is found, the system now checks if there is an untagged inventory spool with the same material, subtype, and color. If found, the RFID tag is automatically linked to that existing spool instead of creating a duplicate entry. Uses FIFO ordering (oldest spool first) so spools are consumed in purchase order. Matching is case-insensitive. Requested by @wreuel.
- **External Folder Mounting for File Manager** ([#124](https://github.com/maziggy/bambuddy/issues/124)) — Host directories (NAS shares, USB drives, network storage) can now be mounted into the File Manager without copying files. Click "Link External" to point at a Docker bind-mounted path. Files are indexed into the database on scan but accessed directly from their original location — nothing is copied. Supports read-only mode (default, blocks uploads/moves/deletes), hidden file filtering, and automatic thumbnail extraction for 3MF, STL, gcode, and image files. External folders show a distinct icon and info bar with a rescan button. Deleting an external folder only removes the database index, never the actual files. Requested by @S1N4X.

### Improved
- **SpoolBuddy Kiosk Performance Optimizations** — Reduced idle CPU load on Raspberry Pi from ~3.3 to ~0.9. Frontend: replaced expensive CSS animations on the idle dashboard (`animate-ping` with scale transforms, `blur-2xl` glow, continuous `animate-pulse` on status dots) with static elements and a slow color-cycling spool (5s interval). Chromium: added `--disable-extensions`, `--disable-background-timer-throttling`, `--disable-renderer-backgrounding`, and `--disable-crash-reporter` to `/etc/chromium.d/spoolbuddy-kiosk`. WebSocket: SpoolBuddy Dashboard and Layout pages now use React Query `select` to extract only `connected` status from printer queries, so temperature/fan/progress updates no longer trigger re-renders on every MQTT tick. Services: stripped services are now masked (not just disabled) to prevent socket/dbus reactivation; user-level services (xdg-desktop-portal, mpris-proxy, pipewire, etc.) are masked globally via `/etc/systemd/user/` overrides instead of unreliable `su -l systemctl --user`. Removed chromium and upower from `strip_packages` since the kiosk needs them — they were being uninstalled then immediately reinstalled on every run.
- **SpoolBuddy AMS Slot Action Picker** — Clicking an AMS slot on the SpoolBuddy AMS page now shows a picker with contextual actions: Configure AMS Slot (set filament preset, K-profile, color), and either Assign Spool / Link to Spoolman (when no spool is mapped) or Unassign / Unlink (when one is). Works with both internal inventory and Spoolman. Previously the slot click went straight to the configure modal with no way to manage spool assignments.
- **Unassign Button in Edit Spool Modal** — The edit spool modal now has an "Unassign" button next to "Delete Tag" that removes the spool's AMS slot assignment, clearing the location column in the inventory table.
- **SpoolBuddy Settings Device Tab No Longer Scrolls** — Removed the branding card, folded Device ID into the Device Info card, placed Backend/Auth config and diagnostic buttons side by side in a 2-column layout, removed the redundant online/offline status row from Device Info, and tightened spacing throughout. The Device tab now fits on the small SpoolBuddy touchscreen without scrolling.
- **Spool Notes in Assign Spool Modal** ([#793](https://github.com/maziggy/bambuddy/issues/793)) — Spool cards in the Assign Spool modal now show the spool's note as a hover tooltip, making it easier to identify spools by tracking IDs or other metadata stored in notes. Works with both internal inventory and Spoolman-synced spools. Requested by @LegionCanadian.
- **WiFi Safeguard for SpoolBuddy Pi** — The install script now drops an APT hook (`/etc/apt/apt.conf.d/80-preserve-wifi`) that backs up NetworkManager WiFi connections before every `apt upgrade` and restores them if they get wiped. Prevents headless SpoolBuddy Pis from losing WiFi connectivity after Raspberry Pi OS package upgrades (observed with Bookworm kernel/raspi-config updates that clear `/etc/NetworkManager/system-connections/`).
- **SpoolBuddy Install Script Now Upgrades System Packages** — The install script now runs `apt-get upgrade -y` after installing required packages and the WiFi safeguard. This ensures the Pi is fully up to date before SpoolBuddy is deployed, and the WiFi safeguard protects connectivity during the upgrade.
- **SpoolBuddy Assign-to-AMS Material Mismatch Warnings** — The SpoolBuddy "Assign to AMS" modal now warns when the spool's material or slicer profile doesn't match the target slot's current filament. Shows a confirmation dialog with five warning levels: exact material mismatch, partial material match, profile-only mismatch, and combined material+profile mismatches. Respects the global `disable_filament_warnings` setting. Previously, assigning a spool to an occupied slot proceeded without any validation, matching the behavior already present in the main Assign Spool modal.
- **Spool Assignment Changes Sync Across Tabs** — Assigning or unassigning a spool now broadcasts a WebSocket event to all connected clients. Other open browser tabs and the SpoolBuddy frontend update automatically without requiring a page reload.
- **SpoolBuddy Inventory Page** — Added a new Inventory page to the SpoolBuddy kiosk UI, accessible from the bottom navigation bar between Write and Settings. Shows a responsive catalog grid of spools with colored spool circles (matching AMS page style), material/subtype labels, color dots, fill level bars, remaining weight with percentage, and green AMS location badges (A1, B2, etc.) for assigned spools. Includes a search bar (filters by material, subtype, brand, color, notes) and touch-friendly inline filter pills ("All", "In AMS", per-material). Tapping a spool opens a full-screen detail view with spool icon, remaining bar, AMS assignment, weight breakdown, slicer filament, PA K-profiles (name and value), temperature range, cost, tag ID, and notes. Detail view updates live from query data. Assigned spools sort first. When Spoolman is enabled, the page shows the Spoolman UI instead.
- **SpoolBuddy Auto-Navigate on Tag Scan** — When an NFC tag is detected while the SpoolBuddy UI is on a non-dashboard page (Settings, AMS, Write Tag, etc.), the frontend automatically navigates back to the main dashboard to show the scanned spool. Also wakes the screen if the display was blanked.
- **SpoolBuddy Swipe to Switch Printers** — Swiping left/right on the SpoolBuddy touchscreen now cycles through online printers instead of triggering browser back/forward navigation. The selected printer updates in the top bar dropdown. Requires at least two online printers; single-printer setups are unaffected.
- **SpoolBuddy Virtual Keyboard Layout Fix** — The virtual keyboard now participates in the flex layout instead of overlaying as a fixed element. When the keyboard opens, the bottom nav and status bar are hidden and the content area shrinks to fit, eliminating the dead space gap between content and keyboard on the Inventory page. Number inputs (e.g. Weight field on Write Tag) now accept virtual keyboard input.
- **Removed Diagnostic Buttons from Write Tag Page** — Removed the "NFC Diag" and "Scale Diag" buttons from the NFC status panel on the Write Tag page. These diagnostics are accessible from the Settings page and don't belong on the tag writing flow.
- **SpoolBuddy Assign Spool Modal No Longer Clips Display** — The shared Assign Spool modal overflowed off-screen on the small SpoolBuddy touchscreen, hiding the footer buttons. Added scoped CSS in the SpoolBuddy AMS page that caps the modal at 90vh with a scrollable spool list, without affecting the main Bambuddy frontend.
- **SpoolBuddy System Tab** — Added a "System" tab to SpoolBuddy Settings showing live OS stats from the Raspberry Pi: CPU temperature, core count, load average, memory usage, disk usage, OS distro/kernel/architecture, Python version, and system uptime. Stats are collected by the daemon every heartbeat (10s) using stdlib-only reads from `/proc` and `/sys` — no additional dependencies required. Usage bars turn amber at 70% and red at 90%; CPU temperature is color-coded green/amber/red.
- **SpoolBuddy Boot Splash Polished** — New splash image displays only the SpoolBuddy logo (removed Bambuddy branding) with green glow bloom, radial gradient background, light rays, and vignette. A generator script (`generate_splash.py`) is included for easy customization. Also reduced redundant initramfs rebuilds during install by deferring the rebuild until after the Plymouth theme is configured.

### Security
- **Token-Based Auth for Media Endpoints** — Camera streams, snapshots, thumbnails, timelapse videos, photos, QR codes, and cover images served via `<img>`/`<video>` tags now require a stream token query parameter (`?token=xxx`) when authentication is enabled. Previously these endpoints were unauthenticated because browser media elements cannot send `Authorization` headers. The frontend obtains a 60-minute reusable token via `POST /printers/camera/stream-token` (requires `CAMERA_VIEW` permission) and automatically appends it to all media URLs. Affects endpoints in camera, archives, library, printers, print-log, and external-links routes. When auth is disabled (default for local installs), behavior is unchanged — no token required.

### Fixed
- **Native Install Misdetected as Docker in LXC Containers** — The update check falsely identified native installs as Docker when running inside Proxmox LXC containers. The detection logic used `.git/` directory absence as a Docker fallback, but LXC containers may also lack `.git/` depending on how the install was deployed. Replaced the `.git/` fallback with a proper check of `/run/systemd/container` which only matches Docker/Podman/OCI runtimes, not LXC. Native installs in LXC containers now correctly show the in-app update button instead of Docker Compose instructions.
- **Print Fails on Files With Spaces in Name** ([#824](https://github.com/maziggy/bambuddy/issues/824)) — Printing files with spaces in their filename (e.g. "Junktion Box PRO 90.3mf") caused the printer to silently ignore the print command and remain IDLE. The FTP upload succeeded, but the MQTT print command's `url` field (`ftp://file name.3mf`) contained unencoded spaces that the firmware couldn't parse. Fixed by replacing spaces with underscores in the remote filename before upload.
- **SpoolBuddy Low Filament Warning Missing Slot Number** — The status bar low filament warning showed "AMS B" instead of the specific slot like "B2". Now uses `formatSlotLabel` to display the full slot label (e.g. "Low Filament: PLA (B2) - 4% remaining").
- **SpoolBuddy Read Tag Diagnostic Fails on NTAG Tags** — The `read_tag.py` diagnostic script had five issues preventing NTAG reads: (1) SAK `0x04` (MIFARE Ultralight family) was rejected as "unsupported tag type" — now accepts both `0x00` and `0x04`. (2) `ntag_read_pages` had TX CRC off (should be on per NTAG spec), no Crypto1 clear, and no IDLE→TRANSCEIVE state reset. (3) The PN5180 enters an unrecoverable state after an NTAG READ command — added full GPIO hardware reset between each 4-page batch. (4) Reading past the end of smaller tags (MIFARE Ultralight has 16 pages vs NTAG's 44+) caused a hard failure — now returns partial data gracefully. (5) `ntag_write_page`/`ntag_write_pages` had the same stale CRC/state issues plus unreliable ACK checking and post-write verification — synced with daemon.
- **Delete Tag Leaves Stale Tag Type** — The "Delete Tag" button in the spool edit modal only cleared `tag_uid` but left `tray_uuid`, `tag_type`, and `data_origin` intact. All tag-related fields are now cleared together.
- **SpoolBuddy NFC Write Fails on NTAG Tags** — Multiple issues prevented writing to NTAG 213/215/216 tags. (1) Some chips report SAK `0x04` (MIFARE Ultralight family) instead of `0x00` during anticollision — both `0x00` and `0x04` are now accepted. (2) TX CRC was disabled for NTAG commands but the spec requires it — enabled for both WRITE and READ. (3) The PN5180 state machine needed IDLE→TRANSCEIVE resets (not just `set_transceive_mode()`) and Crypto1 cleared before NTAG operations. (4) The 4-bit WRITE ACK cannot be captured by the PN5180 (SOF detected but no RX_IRQ) — removed per-page ACK checking. (5) Post-write read-back verification also failed (second READ command gets no response from the PN5180) — removed verification since the tag reliably ACKs each write.
- **Database Connection Pool Exhaustion on Large Printer Farms** — Users with 100+ printers connected simultaneously experienced `QueuePool limit of size 10 overflow 20 reached, connection timed out` errors. Increased the SQLAlchemy connection pool from 30 total (10 base + 20 overflow) to 220 (20 base + 200 overflow), and raised the SQLite busy_timeout from 5 to 15 seconds to reduce write contention under heavy concurrent MQTT updates.
- **SpoolBuddy Update Check Always Shows "Up to Date"** — The SpoolBuddy daemon update check compared the device's firmware version against GitHub releases instead of the running Bambuddy backend version. This meant the check could incorrectly report "up to date" even when the daemon was behind. Fixed by comparing directly against `APP_VERSION` from the backend config.
- **SpoolBuddy Updates Now Use SSH** — Replaced the fragile self-update mechanism (daemon pulls its own code via git, permission errors on `.git/`, hardcoded `main` branch) with SSH-based updates driven by the Bambuddy backend. Bambuddy now SSHes into the SpoolBuddy Pi and runs git fetch/checkout, pip install, systemctl restart, and kiosk browser restart remotely. Updates automatically use the same branch as Bambuddy. SSH key pairing is fully automatic — Bambuddy generates an ED25519 keypair and includes the public key in the device registration response; the daemon deploys it to `authorized_keys` on first connect. The install script creates the `spoolbuddy` user with a bash shell and sudoers entries for daemon and kiosk restart. A "Force Update" button allows re-deploying even when versions match. The SSH public key is also shown in SpoolBuddy Settings → Updates → SSH Setup for manual pairing if needed.
- **Frontend Not Updating After Deploy** — The service worker used stale-while-revalidate for JS/CSS assets, serving the old cached bundle even after a new build was deployed. Changed to network-first for JS/CSS (Vite content-hashes filenames so cache-busting is built in), bumped SW cache version, and added `Cache-Control: no-cache` to the `sw.js` endpoint so browsers always pick up new service worker versions immediately. The SpoolBuddy kiosk now skips SW registration entirely and unregisters any existing SW — a touchscreen kiosk has no use for offline caching and it was the main source of stale frontend issues after updates.
- **SpoolBuddy Kiosk Starts Before Network Is Ready** — On fresh installs, the kiosk browser launched before the network was fully up, showing a connection error for 10-15 seconds until connectivity was restored. The getty@tty1 autologin override now waits for `network-online.target` so Chromium has connectivity when it starts.
- **SpoolBuddy Update UI Stale After Restart** — After a SpoolBuddy update, the UI permanently showed the old version and "update available" because: (1) the SSH update set status to `"complete"` after the daemon had already re-registered, overwriting the cleared state; (2) the kiosk restart navigated away from the updates page; (3) query cache served stale data. Fixed by letting daemon re-registration clear all update status, removing the kiosk restart in favor of a frontend-driven `window.location.reload()` triggered via WebSocket when the daemon comes back online, and adding proper loading states to Check/Force Update buttons.
- **Virtual Printer Proxy A1 Printing Fails** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — BambuStudio could not send prints to A1 (and potentially P1S) virtual printers in proxy mode. The slicer connects to undocumented proprietary ports 2024-2026 on these models, which the proxy was not forwarding, causing BambuStudio to show an access code dialog instead of printing. Added transparent TCP pass-through proxying for ports 2024-2026. These ports are silently ignored on models that don't use them (X1C, H2C, P2S). Also added ports 2024-2026 to the docker-compose.yml bridge-mode port mapping.
- **Spool Assignment on Empty AMS Slots** ([#784](https://github.com/maziggy/bambuddy/issues/784)) — Empty AMS slots (no physical spool detected) showed "Assign Spool" and "Configure" buttons in the hover popup. Assigning a spool to an empty slot created a stuck state because no "Unassign" button is available for empty slots. Truly empty slots now hide both buttons, while slots with a spool inserted but filament not loaded still show configure/assign. Also fixed stale AMS slot data on H2D and other printers that only send `{id, state}` in incremental MQTT updates — filament load/unload transitions now update in real-time without requiring a reconnect.
- **Spoolman Sidebar Opens Root URL Instead of Spool Page** — When Spoolman is enabled, clicking the Filament sidebar item embedded Spoolman at its root URL instead of the spool management page. The iframe now navigates to `<spoolman_url>/spool`.
- **Log Flood: "State is FINISH but completion NOT triggered"** ([#790](https://github.com/maziggy/bambuddy/issues/790)) — A diagnostic log message introduced in 0.2.2.1 fired on every MQTT update while a printer sat in FINISH or FAILED state, flooding logs with thousands of lines per minute in printer farms. Fixed by only logging once on the initial state transition, and marking `_completion_triggered = True` when a terminal state is first seen without a prior RUNNING state so the flag is clean for the next print cycle.
- **H2D External Spool Print Fails With "Failed to get AMS mapping table"** ([#797](https://github.com/maziggy/bambuddy/issues/797)) — Printing from an external spool on H2D (and H2D Pro) through Bambuddy failed with `0700_8012 "Failed to get AMS mapping table"`, while the same print worked fine from BambuStudio. Bambuddy was passing raw virtual tray IDs (254/255) in the flat `ams_mapping` array, but BambuStudio converts these to -1 and relies on `ams_mapping2` for external spool routing. The H2D firmware rejects raw 254/255 in the flat array. Also fixed the `ams_mapping2` format for external trays — each virtual tray is its own AMS unit with `slot_id: 0`, not a shared unit differentiated by slot.
- **SpoolBuddy Scale First Reading Always Wrong** — The NAU7802 ADC always returns a stale max-scale value (`0x7FFFFF`) on its first conversion after power-up, which polluted the moving average and made the initial weight report wildly inaccurate. Fixed by flushing the first reading during `init()` so all subsequent reads return valid data. Also extracted both hardware drivers out of diagnostic scripts into proper modules — the NAU7802 scale driver from `scripts/scale_diag.py` into `daemon/nau7802.py`, and the PN5180 NFC driver from `scripts/read_tag.py` into `daemon/pn5180.py`. The production daemon was importing driver classes from test scripts since the original SpoolBuddy commit. Removed the now-unnecessary `sys.path` hack from `main.py`.
- **ffmpeg Process Leak Causing Memory Growth** ([#776](https://github.com/maziggy/bambuddy/issues/776)) — Camera stream ffmpeg processes accumulated over time, consuming several GB of RAM. When a user closed the camera viewer, the frontend sent a stop signal that killed the ffmpeg process, but the backend stream generator interpreted the dead process as a dropped connection and respawned ffmpeg — up to 30 reconnection attempts per stream. The orphan cleanup couldn't catch these because they were tracked as "active". Fixed by signaling the generator's disconnect event from the stop endpoint before killing the process, checking for stream removal before reconnecting, and tracking frame timestamps per-stream instead of per-printer so stale detection works correctly when multiple streams exist. Reported by @ChrisTheDBA,

## [0.2.2.1] - 2026-03-22

### New Features
- **SpoolBuddy OTA Updates** — SpoolBuddy devices can now be updated directly from the Settings → Updates tab without SSH access. Click "Check for Updates" to see if a newer version is available, then "Apply Update" to trigger the update. The daemon picks up the command via its heartbeat, pulls the latest code from GitHub, installs dependencies, and restarts automatically via systemd. Live progress is shown in the UI with status messages from the device. The status bar at the bottom automatically checks for updates every 5 minutes and shows a prominent message when one is available. Requires the device to be online.
- **Select Plates to Queue** ([#777](https://github.com/maziggy/bambuddy/issues/777)) — Multi-plate 3MF files now support selecting a subset of plates to queue, instead of only "one plate" or "all plates". In add-to-queue mode, each plate has a checkbox for multi-select, with a "Select All / Deselect All" toggle. Reprint and edit modes remain single-select. Requested by @stringham.
- **Camera Image Rotation** ([#672](https://github.com/maziggy/bambuddy/issues/672)) — Added per-printer camera rotation (0°, 90°, 180°, 270°) for cameras mounted in portrait or upside-down orientations. Configurable in Settings → Camera for each printer. Rotation applies to live stream, embedded viewer, stream overlay, and notification snapshots. Requested by @wrenoud.
- **Per-User Email Notifications** ([#693](https://github.com/maziggy/bambuddy/pull/693)) — When Advanced Authentication is enabled, individual users can now receive email notifications for their own print jobs. A new "Notifications" page lets each user toggle notifications for print start, complete, failed, and stopped events. Only prints submitted by that user trigger their email — other users' prints are not affected. Requires SMTP to be configured and the "User Notifications" toggle enabled in Settings → Notifications. Administrators and Operators have access by default; Viewers do not. Contributed by @cadtoolbox.

### Fixed
- **SpoolBuddy Daemon Reports Stale Version** — The SpoolBuddy daemon maintained its own hardcoded `__version__` that was never bumped to `0.2.3b1`, causing the update check to incorrectly show an update from `0.2.2b1` to the latest release. Fixed by reading the version at import time from the backend's `APP_VERSION` in `backend/app/core/config.py` — the single source of truth — so the daemon version is always in sync.
- **SpoolBuddy Update Columns Missing from Database** — The OTA update feature added `update_status` and `update_message` to the device model but was missing the database migration, causing "no such column" errors on existing installations.
- **Queue Print Command Not Reaching Printer** ([#778](https://github.com/maziggy/bambuddy/issues/778)) — When a queue item targeted a specific printer and the scheduler's power-on-wait loop triggered, each reconnection attempt created a new MQTT client that re-attempted subscribing to the request topic. On printers whose broker rejects this subscription (e.g. A1), this caused repeated connect/disconnect cycles for up to 170 seconds, leaving the MQTT connection in a fragile state where the print command could silently fail to reach the printer. Fixed by caching request topic support state per serial number at the class level, so new client instances skip the subscription immediately instead of rediscovering the rejection. Reported by @RubenKremer.
- **Stale MQTT Connection Not Recovering** ([#813](https://github.com/maziggy/bambuddy/issues/813)) — When a printer's MQTT connection went stale (no messages for 60+ seconds), Bambuddy marked it as disconnected but did not force the underlying TCP socket closed, so paho-mqtt's auto-reconnect never triggered and print commands were silently published into a dead connection. Fixed by force-closing the socket on stale detection so paho's loop thread detects the break and auto-reconnects. The initial fix caused rapid connected/disconnected bouncing in the UI because frontend status polls triggered repeated socket force-closes before paho could finish reconnecting; added a 30-second cooldown between stale reconnect attempts so paho has time to re-establish the connection. Also uses a flag to suppress the redundant disconnect callback broadcast. Relaxed MQTT keepalive from 15s to 30s — the aggressive 15s keepalive caused spurious disconnects on transient network hiccups. Added reconnect backoff (1-30s) and unique-per-process MQTT client IDs to prevent broker session takeovers. Error disconnects (`rc.is_failure`) are never suppressed by the spurious-disconnect filter. The disconnect event used by `disconnect()` is fired unconditionally at the top of the callback so that no early-return filter can prevent it from unblocking callers. Reported by @inkdawgz.
- **P1S/P1P Printer Card Shows "Printing" When Idle** ([#813](https://github.com/maziggy/bambuddy/issues/813)) — Some P1S and P1P firmware versions report `stg_cur=0` when idle, which maps to the "Printing" stage name and overrides the correct "Idle" gcode_state on the printer card. The System Info page was unaffected because it displays the raw gcode_state. Extended the existing A1/A1 Mini workaround for this firmware bug to also cover P1S and P1P models. Reported by @inkdawgz.
- **AMS Slot Search Shows Unrelated Profiles** ([#681](https://github.com/maziggy/bambuddy/issues/681)) — Searching for a non-existent filament profile in the AMS slot configuration showed unrelated profiles instead of an empty result. The saved preset bypassed the search filter entirely, so stale mappings (e.g. a slot previously configured with "Bambu PLA Matte" that now holds a Silk spool) would always appear regardless of the search query. The saved preset now only bypasses the printer model filter, not the search filter. Reported by @RosdasHH.
- **Virtual Printer FTP Routed to Wrong VP** ([#735](https://github.com/maziggy/bambuddy/issues/735)) — When running multiple virtual printers with different access codes on separate bind IPs, FTP connections were routed to the wrong VP. Root cause: the iptables `REDIRECT` rule rewrites the destination IP to the incoming interface's primary address, so all FTP traffic went to the first VP regardless of the intended target. Fix: FTP server now binds directly to port 990 (standard implicit FTPS), eliminating the need for iptables redirect. Requires `CAP_NET_BIND_SERVICE` (already set in the systemd service and Docker image). Also removed a global `set_exception_handler()` in the MQTT server that caused spurious error messages when running multiple VPs. See `docs/migration-vp-ftp-port.md` for migration steps. Reported by @VREmma.
- **X1C Virtual Printer Not Accepting Sends** ([#735](https://github.com/maziggy/bambuddy/issues/735)) — X1C (and X1) virtual printers were advertised with legacy SSDP model codes (`3DPrinter-X1-Carbon` / `3DPrinter-X1`) that BambuStudio doesn't recognize, causing "incompatible printer preset" when sending. Fixed to use the correct codes (`BL-P001` / `BL-P002`). Also fixed proxy mode auto-inherit storing the printer's display name (e.g. `X1C`) instead of the SSDP code. Existing VPs are automatically migrated on startup. Reported by @RosdasHH.
- **White Filament Color Swatches Invisible in Light Theme** ([#726](https://github.com/maziggy/bambuddy/issues/726)) — Filament color circles used a white border that was invisible against light theme backgrounds, making white spools indistinguishable. Changed to a dark border (`border-black/20`) across all views: Inventory, Archives, Assign Spool, Configure AMS Slot, Calendar, Projects, Filament Trends, Local Profiles, Link Spool, and Spoolman Settings. Reported by user.
- **Camera Window Overlapping Modals** ([#738](https://github.com/maziggy/bambuddy/issues/738)) — Floating camera viewer rendered on top of modals (e.g. Assign Spool), making them unusable. Lowered camera z-index so modals always appear above it. Reported by @maziggy.
- **Print Complete Notification Not Firing** ([#736](https://github.com/maziggy/bambuddy/issues/736)) — Print complete notifications could silently fail if the finish photo capture hung or timed out, because the notification was chained behind the photo task with no timeout. Added a 45-second timeout so notifications always send even if photo capture stalls. Also added diagnostic logging for MQTT state detection to trace completion triggers. Reported by @piatho.
- **Webhook Notifications Missing Camera Snapshot** ([#679](https://github.com/maziggy/bambuddy/issues/679)) — Webhook notification providers did not include camera snapshots (e.g. from First Layer Complete notifications), even though providers like Telegram, Pushover, ntfy, and Discord already attached them. The webhook payload now includes a base64-encoded `image` field when a snapshot is available (generic format only, not Slack format). Reported by @Arn0uDz.
- **Mobile Sidebar Not Scrollable** — On mobile devices with many navigation items, the sidebar did not scroll, making bottom items unreachable. Added overflow scrolling to the nav section while keeping the logo and footer pinned.
- **User Notification Ruff/Lint Fixes** ([#693](https://github.com/maziggy/bambuddy/pull/693)) — Fixed missing `timezone` import in email timestamp, unused lambda argument, PEP 8 blank line spacing for `mark_printer_stopped_by_user`, and SQLAlchemy forward reference in `UserEmailPreference` model.
- **Carbon Rod Lubrication Maintenance Task Incorrect** ([#755](https://github.com/maziggy/bambuddy/issues/755)) — X1/P1 series printers showed a "Lubricate Carbon Rods" maintenance task, but carbon rods use plain bearings and should never be lubricated — doing so degrades print quality. Removed the lubrication task; only "Clean Carbon Rods" remains. Existing "Lubricate Carbon Rods" entries are automatically removed on next startup. Reported by @RosdasHH.
- **Ntfy Notifications Fail With Non-ASCII Characters** ([#742](https://github.com/maziggy/bambuddy/issues/742)) — Ntfy notifications with camera snapshots failed when the printer name or filename contained non-ASCII characters (e.g. accented letters, CJK). The `Title` and `Message` HTTP headers were passed as Python strings, causing httpx to reject them with `UnicodeEncodeError`. Fixed by encoding header values as UTF-8 bytes, which ntfy handles correctly. Test notifications were unaffected because they use a hardcoded ASCII title and no image attachment. Reported by @user.
- **Virtual Printer Proxy Mode Printing Fails on Isolated Networks** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — When the slicer and printer are on different VLANs/subnets, Bambu Studio could not send prints through the virtual printer proxy because: (1) the printer's real IP leaked through MQTT payloads (`rtsp_url`, `net.info[].ip`), causing BS to bypass the proxy; (2) the bind/detect protocol (port 3000/3002) was forwarded to the real printer, leaking its identity and name; (3) the file transfer tunnel (port 6000) used by BS for verify_job and uploads was not proxied; (4) FTP data connections for zero-byte uploads (verify_job) failed due to a TLS handshake race condition. Fixed by: rewriting IP addresses in MQTT PUBLISH payloads (both string and integer formats) with proper MQTT framing preservation, responding to bind/detect with the VP's own identity via BindServer, adding transparent TCP proxies for port 6000 (file transfer) and port 322 (RTSP camera), buffering slicer data during FTP data proxy connection setup, and advertising the configured VP name in SSDP. Also added cross-subnet SSDP support via a wildcard listener for VPN/multi-subnet setups. Reported by @Utility9298.
- **Virtual Printer Proxy Mode X1C/X1 Print Upload Fails** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — X1C and X1 printers failed to upload prints through proxy mode. After FTP verify_job succeeded (226), BambuStudio's closed-source `bambu_networking` DLL silently refused to proceed with the actual 3MF upload, showing a login modal instead. Root cause: the DLL validates the TLS connection parameters and rejects connections where the certificate doesn't match the printer's real BBL CA certificate. The TLS-terminating proxy presented Bambuddy's own "Virtual Printer CA" certificate, which the DLL rejected. Fixed by switching to transparent TCP proxying for FTP (port 990), FileTransfer (port 6000), Camera (port 322), and FTP passive data (ports 50000–50100) — raw bytes are forwarded without TLS termination, so the slicer gets end-to-end TLS directly with the printer's real certificate. Only MQTT (port 8883) remains TLS-terminated, which is required to rewrite the printer's real IP with the proxy's bind IP in MQTT payloads. Confirmed working on both H2D and X1C printers.
- **UserEmailPreference Model Not Registered** — The `UserEmailPreference` SQLAlchemy model was not imported in `models/__init__.py`, causing mapper initialization failures when the `User` model's relationship resolved the string reference before the model class was registered with Base metadata.
- **Native Install Missing CAP_NET_BIND_SERVICE** — The `install.sh` systemd service template was missing `AmbientCapabilities=CAP_NET_BIND_SERVICE`, causing Virtual Printer proxy mode to silently fail to bind privileged ports (322, 990) on native installations.
- **Virtual Printer Proxy A1 Diagnostics** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — Added diagnostic port probing (ports 21, 80, 443) on proxy VP bind IPs to detect if BambuStudio tries to connect on ports the proxy doesn't handle. Logs a warning when an unexpected connection is detected. Helps diagnose A1/A1 Mini proxy issues where the slicer may use a different connection flow.
- **File Rename Removes Extension** ([#751](https://github.com/maziggy/bambuddy/issues/751)) — Renaming a file in the File Manager included the file extension in the editable text, so users could accidentally remove it (e.g. renaming `bracket.gcode.3mf` to `bracket`), making the file unprintable. The rename modal now only lets users edit the base name, with the extension shown as a non-editable suffix. Reported by @fleishmaab, confirmed by @cadtoolbox.
- **Spurious "Job Waiting for Filament" Notification** ([#753](https://github.com/maziggy/bambuddy/issues/753)) — When all printers of a model were busy and a job was queued with ASAP timing, a "Job Waiting for Filament" notification fired immediately even though no filament issue existed. The job was simply waiting for a printer to finish. The scheduler now skips the waiting notification when all matching printers are just busy, since the job will auto-start when one finishes. Also renamed the default notification title from "Job Waiting for Filament" to "Queue Job Waiting" to accurately reflect all waiting reasons. Reported by @maziggy.
- **AMS Spools Removed After Printer Restart** ([#765](https://github.com/maziggy/bambuddy/issues/765)) — AMS spool assignments and slot configurations were lost after restarting the printer. When the printer shuts down, it sends a final MQTT message with `tray_exist_bits=0` and `power_on_flag=false`, which caused Bambuddy to clear all AMS slot data and auto-unlink every spool assignment. On reconnect, the assignments were gone. Fixed by skipping `tray_exist_bits` slot clearing when `power_on_flag` is `false` (shutdown message), preserving AMS data across printer restarts. Reported by @Woyteck1.

### Community Contributions
- **Admin Set Default Nav-Menu Order** ([#761](https://github.com/maziggy/bambuddy/pull/761)) — Admins with authentication enabled can now set their current sidebar menu order as the default for new users. New users inherit this layout on first login and can customize it afterward. Contributed by @cadtoolbox.
- **Improve Home Assistant Notifications** ([#750](https://github.com/maziggy/bambuddy/pull/750)) — Added support for Home Assistant `notify` services in addition to the existing REST-based integration. Contributed by @mrtncode.
- **Add Total Cost to Projects** ([#733](https://github.com/maziggy/bambuddy/pull/733)) — The Projects page now shows a total cost that sums material, energy, and BOM costs. Contributed by @Keybored02.
- **Material Mismatch & Insufficient Filament Checks** ([#720](https://github.com/maziggy/bambuddy/pull/720)) — When assigning non-Bambu Lab spools, a warning prompts if the filament type or profile doesn't match. Pre-print checks now also warn when the spool has insufficient material. Both warnings are dismissible, with a toggle in Settings. Contributed by @Keybored02.
- **Send Bambu RFID Tags to Spoolman & Manual Mode Unlink** ([#719](https://github.com/maziggy/bambuddy/pull/719)) — Bambu Lab spool RFID identifiers (tray UUID) are now sent to Spoolman instead of generic placeholder tags. An "Unlink" button appears on Bambu spools when Spoolman is in manual sync mode. Fixed location clearing for generic spools during sync. Contributed by @shrunbr.
- **Rework Archive Duplicates Tagging** ([#718](https://github.com/maziggy/bambuddy/pull/718)) — Duplicate detection now requires both matching filename and SHA256 hash. The tag shows reprint count instead of "Duplicate" text, links back to the parent print, and a new "Hide Duplicates" filter is available. Contributed by @Keybored02.

### Added
- **Quick Print Speed Control** ([#256](https://github.com/maziggy/bambuddy/issues/256)) — Added a print speed control badge to the printer card controls row, next to the fan status badges. Click to choose between Silent (50%), Standard (100%), Sport (124%), and Ludicrous (166%) speed presets. The badge shows the current speed percentage with a gauge icon, always visible but disabled when no print is active. Includes optimistic UI updates for instant feedback. Requested by @Sllepper.
- **Spool Rotation During AMS Drying** — Added a "Rotate spool during drying" checkbox to the manual drying popover for AMS 2 Pro and AMS-HT units. Rotates the spool for more even heat distribution. Off by default; resets when opening the popover for a different AMS unit. The firmware silently disables rotation if filament is currently loaded from the unit.
- **Spool Name Column & Filter in Filament Inventory** ([#740](https://github.com/maziggy/bambuddy/issues/740)) — Added a "Spool" column to the filament inventory table that displays the spool catalog entry name (e.g. "Bambu Lab AMS Tray", "Sunlu 1kg"). Enable it via the column visibility menu. Sortable and hidden by default. Also added a spool name filter dropdown next to the brand filter for quick filtering by spool type. Requested by @DMoenning.

### Changed
- **Redesigned Bug Report Debug Log Flow** — Replaced the fixed 30-second debug log collection with an interactive 3-step flow: start debug logging, reproduce the issue at your own pace, then stop & submit. An elapsed timer shows recording duration with auto-stop at 5 minutes. Users now have full control over when to capture logs instead of racing a countdown. The backend splits log collection into separate start/stop endpoints, and the frontend shows a step progress indicator with pulsing active state.

### Improved
- **HMS Error Visibility on Printers Page** ([#772](https://github.com/maziggy/bambuddy/issues/772)) — Improved visibility of printers with HMS errors for large print farms. Added a red "Problem" counter to the status summary bar showing how many connected printers have active HMS errors. The compact-mode status pip (colored dot) now turns red for fatal/serious errors (severity ≤ 2) or amber for common warnings, instead of only showing connection status. Progress bars turn amber when a print is paused. Sorting by status now places printers with HMS errors at the top, above printing and idle printers. Requested by @jimmy-brightz.
- **Print Command Response Verification** ([#737](https://github.com/maziggy/bambuddy/issues/737)) — After sending a print command, BambuBuddy now monitors whether the printer's state changes within 15 seconds. If the printer silently ignores the command (observed on some P1S firmware versions where the MQTT command handler becomes unresponsive), a warning is logged for diagnostics. This aids debugging when users report prints not starting despite BambuBuddy showing success.
- **Compact Assign Spool Modal** ([#725](https://github.com/maziggy/bambuddy/issues/725)) — The "Assign Spool" modal now uses a compact 3-column grid layout instead of a vertical list, showing more spools at once without scrolling. Each card displays the spool name, color, and remaining/total weight. The modal is wider with a taller scroll area. Requested by @RosdasHH.
- **Reformatted AMS Drying Presets Table** ([#732](https://github.com/maziggy/bambuddy/issues/732)) — The drying presets table in Settings now groups columns by AMS type (AMS 2 Pro, AMS-HT) with inline °C and h unit labels next to each input, replacing the previous flat column layout. Requested by @cadtoolbox.

### Security
- **Bump pyOpenSSL 25.3.0 → 26.0.0** — Fixes CVE-2026-27448 (exception swallowing in TLS servername callback) and CVE-2026-27459 (buffer overflow in DTLS cookie callback).
- **Bump pyasn1 0.6.2 → 0.6.3** — Fixes CVE-2026-30922 (stack overflow from deeply nested ASN.1 structures).
- **Bump flatted 3.4.1 → 3.4.2** — Fixes GHSA-rf6f-7fwh-wjgh (prototype pollution via `parse()`). Dev-only dependency (eslint).


## [0.2.2] - 2026-03-16

### New Features
- **First Layer Complete Notification** ([#679](https://github.com/maziggy/bambuddy/issues/679)) — Get notified with a camera snapshot when the first layer finishes printing, so you can check adhesion remotely without watching the whole print. Enable the "First Layer Complete" toggle on any notification provider. Fires once per print when layer 2 begins (confirming layer 1 is done), with a guard against spurious triggers on printer reconnect. Requested by community.
- **Remote AMS Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page. A flame icon appears on supported AMS cards; clicking it opens a popover to select filament type (PLA, PETG, TPU, ABS, ASA, PA, PC, PVA) with official BambuStudio temperature/duration presets, or set temperature manually. When drying is active, a status bar shows the time remaining with a live countdown and stop button. Supported on X1/X1C (fw 01.09+), P1P/P1S (fw 01.08+), H2D (fw 01.02.30+), H2D Pro, and X1E. Not supported on P2S, A1, A1 Mini, H2S, or H2C. Requires `printers:control` permission when authentication is enabled.
- **Queue Auto-Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Automatically dry filament between scheduled queue prints. When enabled in Settings → Print Queue, the scheduler starts drying on idle printers that have upcoming scheduled prints and whose AMS humidity exceeds the configured threshold. Uses conservative parameters (lowest temperature, longest duration) when mixed filament types are loaded. Drying stops automatically when humidity drops below threshold (with a 30-minute minimum to prevent oscillation), when scheduled items are removed, or when the feature is disabled. Optional "block queue" mode delays the next print until drying completes.
- **Configurable Drying Presets** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Customize temperature and duration for each filament type in Settings → Print Queue. Defaults match BambuStudio presets (PLA 55°C/8h, PETG 65°C/8h, etc.) and are used by both the manual drying popover and queue auto-drying. AMS 2 Pro and AMS-HT use separate presets reflecting their different heating capabilities.
- **AMS PSU Detection** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — The drying button is disabled with a tooltip when the AMS lacks sufficient power for drying (e.g. not connected to the external PSU). Reads `dry_sf_reason` from printer firmware and surfaces HMS error codes for AMS 2 Pro and AMS-HT power issues.
- **Ambient Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Automatically keep filament dry on idle printers based on humidity, even without queued prints. Enable "Ambient drying" in Settings → Print Queue to have the scheduler start drying on any idle printer whose AMS humidity exceeds the configured threshold — no scheduled prints required. Uses the same humidity threshold, drying presets, and power constraint detection as queue auto-drying. Both modes can be enabled simultaneously. Requested by community.
- **Assign Spool to Empty AMS Slot** ([#717](https://github.com/maziggy/bambuddy/issues/717)) — Previously, the "Assign Spool" button only appeared on AMS slots that already had a filament profile configured, requiring users to first configure the slot manually before assigning an inventory spool — even though the assignment auto-configures the slot anyway. The "Assign Spool" option now appears on empty (unconfigured) slots as well. Selecting a spool auto-configures the slot with the correct filament profile, color, and K-profile in one step. Also fixed the AMS slot profile label showing the generic material type (e.g. "PLA") instead of the spool's actual slicer preset name (e.g. "PolyLite PLA Pro") after assignment. Requested by @RosdasHH.
- **Home Assistant Notification Provider** ([#656](https://github.com/maziggy/bambuddy/issues/656)) — Added Home Assistant as a notification provider. When HA is configured in Settings → Network → Home Assistant, selecting "Home Assistant" as a notification provider sends persistent notifications to the HA dashboard — no additional configuration needed. From there, HA automations can forward notifications to mobile apps, WhatsApp, or any other service. Requested by @TravisWilder.
- **Virtual Printer Queue Auto-Dispatch Toggle** ([#587](https://github.com/maziggy/bambuddy/issues/587)) — Added an "Auto-dispatch" toggle to virtual printers in Queue mode. When enabled (default), prints sent from the slicer are added to the queue and start automatically on the assigned printer — matching the current behavior. When disabled, prints are added to the queue with `manual_start` set, so they wait for manual dispatch. This allows users who want to review and manually assign prints before they start. Requested by @Percy2Live.
- **Queue All Plates** ([#530](https://github.com/maziggy/bambuddy/issues/530)) — Multi-plate 3MF files can now be queued in one action. When adding a multi-plate file to the queue, a "Queue All N Plates" toggle appears in the plate selector. When activated, every plate is added as a separate queue entry (one per plate × per selected printer), each individually editable from the queue page. The toggle is only available in add-to-queue mode (not reprint or edit). Requested by @Dendrowen.
- **Malaysian Ringgit Currency** ([#634](https://github.com/maziggy/bambuddy/issues/634)) — Added MYR (RM) to the list of supported currencies for filament cost tracking. Requested by @cynogen127.
- **ETA Variable in Notifications** ([#638](https://github.com/maziggy/bambuddy/issues/638)) — Added `{eta}` template variable to print start, print progress, and queue job started notifications. Shows the estimated wall-clock completion time (e.g. "15:53" or "3:53 PM") based on the user's configured time format (12h/24h). Existing `{estimated_time}` still shows duration ("1h 23m"). Requested by @SebSeifert.
- **Bulk Delete Spool and Color Catalog Entries** ([#646](https://github.com/maziggy/bambuddy/issues/646)) — Added checkbox selection and bulk delete to both the Spool Catalog and Color Catalog in Settings > Filament. Select individual entries with checkboxes, use the header checkbox to select/deselect all visible entries, then click "Delete Selected" to remove them in one operation. Previously, entries could only be deleted one at a time. Requested by @SebSeifert.
- **Force Color Match** ([#625](https://github.com/maziggy/bambuddy/pull/625)) — Added a "Force Color Match" option for "Print to Any" queue scheduling. When enabled, the scheduler requires a strict color match when assigning prints to printers, preventing incorrect filament assignments when multiple candidates are close in color. Prints wait in the queue until a printer with the exact matching filament is available. Contributed by @cadtoolbox.
- **Israeli New Shekel Currency** — Added ILS (₪) to the list of supported currencies for filament cost tracking.
- **AMS Info Card & Custom Labels** ([#570](https://github.com/maziggy/bambuddy/pull/570)) — Hovering an AMS label (e.g. "AMS-A") on the Printers page now shows a popover with serial number, firmware version, and an editable friendly name. Custom labels are stored by AMS serial number so they persist when the unit is moved to a different printer. Slot numbers are now displayed inside each filament color circle with auto-inverted contrast for readability. Labels also appear in the Inventory page's location column. Contributed by @cadtoolbox.
- **In-App Bug Reporting** — A floating bug report button in the bottom-right corner lets users submit bug reports directly from the Bambuddy UI. Reports include a description, optional screenshot (upload, paste, or drag & drop with automatic JPEG compression), optional contact email, and automatically collected diagnostic data. On submit, the system temporarily enables debug logging, sends push_all to all connected printers, waits 30 seconds to collect fresh logs, then submits everything to a secure relay on bambuddy.cool which creates a GitHub issue with sanitized logs uploaded as a separate file. All sensitive data (printer names, serial numbers, IPs, credentials, email addresses) is redacted from logs before submission. The expandable data privacy notice details exactly what is and isn't collected. Translated into all 7 supported languages.
- **SpoolBuddy NFC Tag Writing (OpenTag3D)** — SpoolBuddy can now write NFC tags for third-party filament spools using the OpenTag3D format on NTAG213/215/216 stickers. A new "Write" page (`/spoolbuddy/write-tag`) in the kiosk UI provides three workflows: write a tag for an existing inventory spool (no tag linked yet), create a new spool and write in one flow, or replace a damaged tag (unlinks old, writes new). The left panel shows a searchable spool list or a compact creation form (material dropdown, color picker, brand, weight); the right panel shows real-time NFC status with tag detection, a spool summary, and the write button. The backend encodes spool data as a 133-byte OpenTag3D NDEF message (MIME type `application/opentag3d`, fits NTAG213's 144-byte capacity) containing material, color, brand, weight, temperature, and RGBA color data. The write command flows through the existing heartbeat polling mechanism — the frontend queues a write, the daemon picks it up on the next heartbeat, writes page-by-page with read-back verification via the PN5180's NTAG WRITE (0xA2) command, and reports success/failure via WebSocket. On success the tag UID is automatically linked to the spool with `data_origin=opentag3d`. Written tags are readable by any OpenTag3D-compatible reader including SpoolBuddy itself. Translations added for all 6 languages.
- **SpoolBuddy On-Screen Keyboard** — Added a virtual QWERTY keyboard for the SpoolBuddy kiosk UI (and login page) since the Raspberry Pi has no physical keyboard and system-level virtual keyboards (squeekboard, wvkbd) don't auto-show/hide in the labwc/Chromium kiosk environment. Uses `react-simple-keyboard` with a dark theme matching the bambu-dark/bambu-green palette. Auto-shows when any text/password/email input is focused, supports shift, caps lock, backspace, and email-friendly keys (@, .). Inputs with `data-vkb="false"` are excluded (e.g. SpoolBuddySettingsPage's own numpad). A two-phase close prevents ghost-click passthrough to elements underneath the keyboard.
- **SpoolBuddy Inline Spool Cards** — Placing an NFC-tagged spool on the SpoolBuddy reader now shows spool info directly in the dashboard's right panel instead of a separate modal overlay. Known spools display a SpoolIcon with color/brand/material, a large remaining-weight readout with fill bar, and a weight comparison grid, with action buttons for "Assign to AMS", "Sync Weight", and "Close". Unknown tags show the tag UID, scale weight, and offer "Add to Inventory" or "Link to Spool" actions. The card stays visible if the tag is removed (for continued interaction) and won't re-appear for the same tag after dismissal — but re-placing a tag after removal shows it again. The idle spool animation displays when no tag is detected.
- **SpoolBuddy AMS Page: External Slots & Slot Configuration** — The SpoolBuddy AMS page (`/spoolbuddy/ams`) now displays external spool slots (single nozzle: "Ext", dual nozzle: "Ext-L"/"Ext-R") and AMS-HT units in a compact horizontal row below the regular AMS grid, fitting within the 1024×600 kiosk display without scrolling. Clicking any AMS, AMS-HT, or external slot opens the `ConfigureAmsSlotModal` to configure filament type and color — the same modal used on the main Printers page. Dual-nozzle printers show L/R nozzle badges on each AMS unit. Temperature and humidity are displayed with threshold-colored SVG icons (green/gold/red) matching the Bambu Lab style on the main printer cards, using the configured AMS humidity and temperature thresholds from settings.
- **SpoolBuddy Dashboard Redesign** — Redesigned the SpoolBuddy dashboard with a two-column layout: left column shows device connection status (scale and NFC with state-colored icons — green when device is online, gray when offline) and printer status badges below (compact pills with green/gray dots for online/offline, wrapping to fit without scrolling); right column shows the current spool card. Cards use a dashed border style for a cleaner look. The large weight display card was removed in favor of the inline scale reading in the device card. Unknown NFC tags now offer a quick-add modal that creates a basic PLA spool entry linked to the tag — with a hint recommending users add spools via the main Bambuddy UI first for full details. The separate SpoolBuddy inventory page was removed since inventory management belongs in the main Bambuddy frontend; the bottom nav now has three tabs (Dashboard, AMS, Settings).
- **SpoolBuddy Kiosk Auth Bypass via API Key** — When Bambuddy auth is enabled, the SpoolBuddy kiosk (Chromium on RPi) was redirected to the login page because the `ProtectedRoute` requires a user object from `GET /auth/me`, which only accepted JWT tokens. The `/auth/me` endpoint now also accepts API keys (via `Authorization: Bearer bb_xxx` or `X-API-Key` header) and returns a synthetic admin user with all permissions. The frontend's `AuthContext` reads an optional `?token=` URL parameter on first load, stores it in localStorage, and strips it from the URL to prevent leakage via browser history or referrer. The install script now includes the API key in the kiosk URL (`/spoolbuddy?token=${API_KEY}`), so the device authenticates automatically on boot without manual login.
- **Daily Beta Builds** — Added a release script (`docker-publish-daily-beta.sh`) that reads the current `APP_VERSION` from config, builds a multi-arch Docker image, pushes to both GHCR and Docker Hub, and creates/updates a GitHub prerelease with changelog notes. Daily builds overwrite the same beta version tag (e.g., `0.2.2b1`) — users pull the latest by re-pulling the tag or using Watchtower. Beta images are never tagged as `latest`. Fixed auto-generated "Contributors" section appearing in GitHub release notes by stripping `@mentions` from changelog text before creating the release.
- **Inventory Scale Weight Check Column** — Added a "Weight Check" column (hidden by default) to the inventory table that compares each spool's last scale measurement against its calculated gross weight (net remaining + core weight). Spools within a ±50g tolerance show a green checkmark; mismatched spools show a yellow warning with the difference and a sync button that trusts the scale reading and resets weight tracking. The backend stores `last_scale_weight` and `last_weighed_at` on each spool whenever weight is synced via SpoolBuddy, and the column tooltip shows scale weight, calculated weight, and difference. Edge case: when scale weight is below core weight (empty spool or not on scale), the comparison treats it as a match since sync can't correct this.

### Fixed
- **Library Upload Doesn't Show New File Until Page Reload** ([#704](https://github.com/maziggy/bambuddy/issues/704)) — After uploading a file in the Library file manager, the file list didn't update until the user reloaded the browser. The upload endpoint used `db.flush()` instead of `db.commit()`, so the new row was only written to the database *after* the response was sent to the client. The frontend immediately refetched the file list upon receiving the response, but a new database session couldn't see the uncommitted row — resulting in stale data. Fixed by committing before the response is returned. Also fixed the same race condition in folder create, folder update, and file update endpoints. Reported by @shadowjig.
- **Printer File Manager Doesn't Auto-Refresh** ([#704](https://github.com/maziggy/bambuddy/issues/704)) — The printer file manager (SD card browser) only fetched the file list once when opened. Files uploaded from BambuStudio/OrcaSlicer while the modal was open wouldn't appear until the user clicked the refresh button or reopened the modal. Now auto-refreshes every 30 seconds while open. Reported by @shadowjig.
- **Database Connection Pool Exhaustion Under Load** ([#704](https://github.com/maziggy/bambuddy/issues/704)) — Background tasks (print scheduler FTP uploads, camera captures, notification sends, timelapse stitching) held database sessions open during slow network I/O, consuming connection pool slots for seconds at a time. With the default pool of 15 connections (size 5 + overflow 10), concurrent operations during print start/complete events could exhaust the pool, causing `QueuePool limit reached` errors and `greenlet_spawn` failures in RFID spool auto-assignment. Doubled the pool to 30 connections (size 10 + overflow 20). Reported by @shadowjig.
- **Block Mode Skips Humidity Auto-Stop** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — When "Wait for drying to complete" was enabled and a printer had pending queue items, the scheduler skipped the humidity auto-stop check entirely. A drying session that reached its humidity target would continue indefinitely instead of stopping after the 30-minute minimum. Now, block mode only prevents starting new drying — already-drying printers still have their humidity checked and stopped when the threshold is met.
- **AMS Fill Level Shows 0% for Non-Viewer Users** ([#676](https://github.com/maziggy/bambuddy/issues/676)) — When authentication was enabled with advanced permissions, users with `inventory:view_assignments` permission saw 0% fill level on AMS slots where inventory spool data had stale `weight_used` values. The fill level fallback chain (Spoolman → Inventory → AMS remain) used nullish coalescing (`??`), which doesn't fall through on `0` — so a stale inventory fill of 0% permanently shadowed the correct real-time AMS remain value from the printer. Now, when inventory says 0% but the AMS hardware reports a positive remain, the inventory value is bypassed in favor of the live AMS data. Viewer users were unaffected because their group lacked `inventory:view_assignments`, so the inventory query never fired and the AMS remain was used directly. Reported by @cadtoolbox.
- **Virtual Printer Proxy Mode Always Shows X1C Model** — Creating a virtual printer in Proxy mode always set the model to X1C regardless of the destination printer, because the frontend hides the model dropdown in proxy mode and the backend defaulted to X1C. Now auto-inherits the model from the target printer when creating or updating a proxy virtual printer (e.g. a proxy pointing at a P1S correctly presents itself as P1S to the slicer). The model also auto-updates when changing the target printer or switching to proxy mode.
- **Cloud Profiles Shared Across All Users** ([#665](https://github.com/maziggy/bambuddy/issues/665)) — When authentication was enabled, Bambu Cloud credentials were stored globally — one account per Bambuddy instance. If User A logged into Cloud, every other user saw User A's account and profiles. User B logging in would overwrite User A's credentials. Cloud credentials are now stored per-user: each user logs into their own Bambu Cloud account independently. When auth is disabled (single-user mode), behavior is unchanged. Also fixed cloud data endpoints (`/cloud/settings`, `/cloud/fields`, preset CRUD) requiring `settings:read` / `settings:update` permissions instead of `cloud:auth` — users who had "Cloud Auth" enabled but "Settings" disabled couldn't load profiles after logging in. Reported by @cadtoolbox.
- **Local Profiles Not Shown in AMS Slot Configuration** — Imported local filament profiles were hidden in the AMS slot configure modal when a printer model was set. The `compatible_printers` filter parsed the stored JSON array as a semicolon-delimited string, so the matching always failed and every local preset was silently skipped. Removed the filter entirely — user-imported profiles should be available on any printer.
- **Interface Aliases Not Shown in Virtual Printer Interface Select** — Interface aliases (e.g. `eth0:1`) added for multi-virtual-printer setups were invisible in the bind IP dropdown. The Docker image didn't include `iproute2`, so the `ip` command wasn't available and the code fell back to ioctl-based enumeration which can only return one IP per interface. Added `iproute2` to the Docker image.
- **P2S Camera Stream Disconnects After a Few Seconds** ([#661](https://github.com/maziggy/bambuddy/issues/661)) — The P2S firmware drops RTSP sessions after a few seconds with an I/O error. Root cause: ffmpeg in the Docker image uses GnuTLS for TLS, and Debian's hardened GnuTLS defaults reject TLS behaviors (renegotiation, legacy ciphers) that some printer firmwares rely on. Added a local TLS termination proxy that uses Python's ssl module (OpenSSL) to handle the TLS connection to the printer, exposing a plain RTSP port to ffmpeg. The proxy rewrites RTSP request-line URLs while preserving Digest auth headers. Also reduced RTSP reconnect delay from 1.0s to 0.2s, added ffmpeg fast-start flags for lower startup latency, and fixed external camera streams being choppy due to double rate-limiting in the proxy layer. Reported by @ddetton, confirmed by @DMoenning.
- **iOS/iPadOS Cannot Reposition Floating Camera** ([#687](https://github.com/maziggy/bambuddy/issues/687)) — The floating camera viewer (embedded camera window on the dashboard) could not be dragged or resized on iOS/iPadOS because it only handled mouse events. Touch input scrolled the page underneath instead of moving the camera window. Added touch event support (`touchstart`/`touchmove`/`touchend`) to both the header drag handle and the resize handle, with `preventDefault` to stop page scrolling during drag. Reported by @dsmitty166.
- **PA-CF / PA12-CF / PAHT-CF Not Treated as Compatible** ([#688](https://github.com/maziggy/bambuddy/issues/688)) — Bambu Lab firmware treats PA-CF, PA12-CF, and PAHT-CF as interchangeable, but the print scheduler and filament override UI used exact string matching. If a 3MF required PA-CF but the AMS had PA12-CF loaded, the scheduler wouldn't assign the job and the filament override dropdown was empty/disabled. Added a filament type equivalence system so these PA variants are treated as compatible in scheduler assignment, AMS slot matching, force color match validation, and the filament override dropdown. Reported by @aneopsy.
- **Force Color Match Toggle Click Target Too Large** ([#688](https://github.com/maziggy/bambuddy/issues/688)) — In the Schedule Print modal, clicking anywhere on the "Force color match" row toggled the checkbox, not just the checkbox and its label. The click target now covers only the checkbox, icon, and label text. Reported by @aneopsy.
- **HA Switch Badge Always Sends Turn On Instead of Toggle** — Clicking a non-script Home Assistant entity (switch, light, input_boolean) on the printer card always sent `turn_on`, which is a no-op when the switch is already on. Now sends `toggle` for non-script entities so the badge click actually toggles the switch state. Script entities still use `turn_on` (stateless trigger).
- **Multiple Plugs Per Printer Crashes Auto-On/Off** — When multiple smart plugs were assigned to the same printer (e.g., a Tasmota plug + an HA switch), the auto-on/auto-off handler called `scalar_one_or_none()` which raises `MultipleResultsFound`. Now fetches all plugs and returns the main (non-script) power plug, matching the API route behavior.
- **Multiple HA Switches Per Printer UNIQUE Constraint** — The migration that removes the UNIQUE constraint on `smart_plugs.printer_id` (to allow multiple HA switches per printer) used an exact string match to detect the constraint in the SQLite schema. Databases created with older SQLAlchemy versions expressed the constraint differently (e.g. quoted column names, table-level `UNIQUE(printer_id)`, or separate indexes), so the migration silently skipped them. Users hit `IntegrityError: UNIQUE constraint failed` when assigning a second HA switch to a printer. Now uses regex pattern matching and also checks for standalone UNIQUE indexes.
- **HMS Notifications for Unknown/Phantom Error Codes** — Printers send many undocumented or phantom HMS error codes that don't correspond to real errors (e.g. calibration status codes after firmware updates). These triggered email/push notifications even though the printer card correctly filtered them out. Flipped the notification logic from "notify all, suppress specific codes" to "only notify for errors with known descriptions", matching the frontend behavior. Also fixed the log message reporting incorrect notification counts.
- **Ethernet Badge Shown on WiFi Printers / MQTT Disconnecting** ([#585](https://github.com/maziggy/bambuddy/issues/585)) — Three bugs in the ethernet badge feature: (1) `home_flag` bit 18 is set on all printers regardless of connection type, so every ethernet-capable model showed the ethernet badge even when connected via WiFi. Replaced bit 18 detection with wifi_signal-based heuristic: printers on ethernet with WiFi disabled report a hardcoded `-90 dBm` sentinel, while real WiFi signals vary. (2) The lazy import used `from app.utils.printer_models` which crashes with `ModuleNotFoundError` in paho-mqtt's background thread (correct path is `backend.app.utils.printer_models`). This killed the MQTT thread entirely, causing all printers to go stale after 60s and repeatedly disconnect/reconnect. (3) WiFi-only models (A1, P1P, etc.) that don't have an ethernet port are excluded via model-based gating. Reported by @cadtoolbox.
- **Inventory Usage Tracker Missing External Spool Mapping** ([#677](https://github.com/maziggy/bambuddy/issues/677)) — When all higher-priority slot-to-tray mapping methods failed (MQTT mapping, print command mapping, queue mapping, color matching), the internal inventory usage tracker fell back to `slot_id - 1` which can never reach external spool IDs (254/255) or AMS-HT IDs (128+). Added position-based resolution using sorted available tray IDs from the printer's AMS state, matching the fix applied to Spoolman tracking in #686. Contributed by @shrunbr.
- **Spool Assignment Applies Wrong Filament Profile** ([#681](https://github.com/maziggy/bambuddy/issues/681)) — Assigning a spool with a specific filament variant (e.g. "Generic PLA Silk") to an AMS slot applied the base profile instead (e.g. "Generic PLA"). The Bambu Cloud API returns only the base `filament_id` for versioned setting IDs (`GFSL99` → `GFL99`), ignoring variant suffixes (`GFSL99_01`). Added a cross-check that compares the resolved filament name against the spool's stored preset name and corrects the filament ID via reverse lookup when they don't match (e.g. `GFL99` → `GFL96` for "Generic PLA Silk"). Also fixed the UI showing a stale preset name (e.g. "Bambu PLA Matte" instead of "Bambu PLA Silk") after assignment — the slot preset mapping was only saved when assigning via SpoolBuddy, not via the PrintersPage hover card. The backend now saves the slot preset mapping using the spool's authoritative `slicer_filament_name` after every successful MQTT configuration, regardless of which UI path triggered the assignment. Reported by @peter-k-de, @RosdasHH.
- **Debug Logging Endpoint 500 Error** — The `GET /api/v1/support/debug-logging` endpoint returned a 500 Internal Server Error when the database contained a timezone-aware timestamp written by a previous version. The duration calculation subtracted a timezone-aware datetime from a naive `datetime.now()`, raising `TypeError`. Now strips timezone info when reading the stored timestamp.
- **Bed Cooled Notification Never Fires** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — The bed cooldown monitor always timed out after 30 minutes without sending a notification. After print completion, P1S (and likely other models) sends partial MQTT status updates that don't include `bed_temper`, so the cached bed temperature stayed frozen at the end-of-print value and never dropped below the threshold. The monitor now sends periodic `pushall` commands to the printer to force fresh temperature data. Also added debug logging to the polling loop for future diagnostics.
- **Notification Provider Missing Event Toggles on Create** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — When creating a new notification provider, the `on_bed_cooled` toggle and all 7 queue event toggles (`on_queue_job_added`, `on_queue_job_assigned`, `on_queue_job_started`, `on_queue_job_waiting`, `on_queue_job_skipped`, `on_queue_job_failed`, `on_queue_completed`) were silently discarded. The create endpoint manually listed each field but omitted these 8 toggles, so they always defaulted to `false` regardless of user selection. Editing an existing provider worked correctly.
- **Clear Plate Prompt Shown for Staged Queue Items** — The "Clear Plate & Start Next" button on the printer card appeared when all pending queue items were staged (`manual_start`/Queue Only), even though the scheduler won't auto-start them. The clear plate prompt now only appears when there are auto-dispatchable items that the scheduler will actually start after the plate is cleared.
- **Ethernet Badge Shown on WiFi-Only Printers** ([#585](https://github.com/maziggy/bambuddy/issues/585)) — The printer card network badge always showed "Ethernet" even on printers without an ethernet port. WiFi-only models (A1, P1P, etc.) are now excluded via model-based gating. Reported by @cadtoolbox.
- **GitHub Backup Required Cloud Login** ([#655](https://github.com/maziggy/bambuddy/issues/655)) — The GitHub backup settings card was completely blocked behind Bambu Cloud authentication, showing "Bambu Cloud login required" even though the backup feature works without it (K-profiles and app settings don't need cloud). Removed the cloud auth gate so GitHub backup can be configured and used without Bambu Cloud. The "Cloud Profiles" checkbox is disabled with a hint when not logged in. Reported by @TravisWilder.
- **GitHub Backup Log Timestamps Off by 1 Hour** — Backup log timestamps in the history table were displayed in UTC instead of the user's local timezone. The local `formatDateTime` function didn't use `parseUTCDate`, so timezone-less timestamps from SQLite were interpreted as local time. Now uses the shared `parseUTCDate` utility for correct UTC-to-local conversion.
- **H2D AMS Units Shown on Wrong Nozzle** ([#659](https://github.com/maziggy/bambuddy/issues/659)) — On the H2D dual-nozzle printer, AMS units were displayed on the wrong nozzle (e.g. both AMS-HT and AMS2 Pro shown on the left nozzle instead of their correct assignments). Three interrelated bugs in the AMS `info` field parsing: (1) the field was parsed as decimal instead of hexadecimal (BambuStudio uses `std::stoull(str, nullptr, 16)`), (2) the extruder ID was extracted as a single bit instead of a 4-bit field, and (3) partial MQTT updates overwrote the full extruder map instead of merging. Now correctly hex-parses the `info` field, extracts the 4-bit extruder ID from bits 8-11, skips uninitialized AMS units (`0xE`), and merges partial updates into the existing map. Reported by @cadtoolbox.
- **SD Card Error After FTP Upload** ([#645](https://github.com/maziggy/bambuddy/issues/645)) — After printing one file, subsequent prints could fail with `0500-C010 "MicroSD Card read/write exception"` until Bambuddy was restarted. The FTP upload used `transfercmd()` for A1 compatibility but skipped reading the server's 226 "Transfer complete" response, leaving the SD card file write unconfirmed. The print command was sent via MQTT before the printer's FTP server had finished flushing the file to disk. Now waits for the 226 confirmation after each upload (with a 60-second timeout for slower models like H2D). Reported by @lanfi89, confirmed by @Bademeister89.
- **P2S Shows Carbon Rod Maintenance Tasks** ([#640](https://github.com/maziggy/bambuddy/issues/640)) — The P2S was incorrectly classified as a carbon rod printer, showing "Lubricate Carbon Rods" and "Clean Carbon Rods" maintenance tasks. The P2S uses hardened steel linear shafts, not carbon fiber rods. Added a new `steel_rod` motion system category and "Lubricate Steel Rods" / "Clean Steel Rods" maintenance tasks specific to the P2S. X1/P1 series continue to show carbon rod tasks; A1/H2 series continue to show linear rail tasks. Reported by @maziggy.
- **Dispatch Toast Stuck After Second Print** — The print dispatch progress toast ("Starting prints…") stayed visible forever after the second print dispatch in a session. The dedup guard (`lastDispatchSummaryRef`) that prevents duplicate completion toasts was never reset between batches, so every single-printer dispatch produced the same summary key (`"first-complete:1:0"`). The first print completed normally, but subsequent completions matched the stale ref and skipped creating the done toast — leaving the progress toast stuck in "Processing" state with no way to dismiss except a page reload. Now resets the dedup guard whenever the dispatch toast is dismissed (auto-dismiss timeout, cleanup events) and when a new batch starts.
- **Archive Card Buttons Overlapping at Narrow Widths** ([#641](https://github.com/maziggy/bambuddy/issues/641)) — The "Reprint" and "Schedule" buttons at the bottom of archive cards overlapped when the browser window was narrower than the card grid expected (e.g. snapped to half-screen on a 2K monitor). The button text labels used a viewport-based `sm:` breakpoint that didn't account for actual card width. Added `overflow-hidden` to the flex buttons and `truncate` to the text spans so labels clip cleanly with ellipsis instead of bleeding into adjacent buttons. Reported by rsocko@outlook.com, confirmed by @dsmitty166.
- **Debug Logging Banner Timer Shows Negative Time** — When enabling debug logging, the banner showed a negative duration (e.g. "-60m -59s") equal to the server's UTC offset. The `enabled_at` timestamp was stored using `datetime.now()` (local time, no timezone indicator), but the frontend interpreted it as UTC. Now stores and compares all debug logging timestamps in UTC.
- **Non-Bambu Lab Spools Can't Link/Unlink to Spoolman** ([#653](https://github.com/maziggy/bambuddy/pull/653)) — The "Link to Spoolman" button was not shown for non-Bambu Lab spools (which lack RFID tag UIDs). Now generates a fallback tag from the printer ID, AMS ID, and tray ID for spools without RFID identifiers. Also added an "Unlink from Spoolman" button for non-Bambu spools that are already linked. Contributed by @shrunbr.
- **Spoolman Location Not Updated on Link/Unlink** ([#669](https://github.com/maziggy/bambuddy/pull/669)) — Linking a spool to Spoolman did not set the spool's location field. Now sets the Spoolman location to the printer name, AMS name, and slot number (e.g. "P2S-1 - AMS-A 3") when linking, and clears it when unlinking. Contributed by @shrunbr.
- **Print Dispatch Toast Disappears Instantly on Fast Uploads** ([#615](https://github.com/maziggy/bambuddy/issues/615)) — When sending a print job, the notification popup disappeared instantly for small files or closed immediately when the progress bar reached 100% for larger files, giving no confirmation that the job was submitted. The dispatch toast now stays visible for 3 seconds after completion, showing a success message (e.g. "1 print started successfully") before auto-dismissing. For very fast uploads where the progress toast was never shown, a fresh confirmation toast is created instead. Reported by @aneopsy.
- **Print Modal Shows Busy Printers as Selectable** ([#622](https://github.com/maziggy/bambuddy/issues/622)) — When printing a file from the file manager, the print modal listed all printers including busy ones. Selecting a busy printer resulted in a failed send notification. The printer selector now fetches each printer's live status and shows a state badge (Idle, Printing, Paused, Preparing, Finished, Failed, Offline). In reprint mode, busy printers are grayed out and not selectable. "Select all" also skips busy printers. In queue mode, busy printers remain selectable since the job will wait. Reported by contact@aito3d.fr.
- **PWA Install Not Available in Chrome** ([#629](https://github.com/maziggy/bambuddy/issues/629)) — Chrome did not show the PWA install prompt because the manifest icons had incorrect dimensions (e.g. 190px wide declared as 192px) and the manifest was missing the `screenshots` entries required for Chrome's richer install UI. Resized all three icons (`android-chrome-192x192.png`, `android-chrome-512x512.png`, `apple-touch-icon.png`) to their declared sizes, split the discouraged `"any maskable"` purpose into a dedicated `"maskable"` entry, and added mobile and desktop screenshots to the manifest. Reported by @SebSeifert.
- **Project Statistics Count Archived Files as Printed** ([#630](https://github.com/maziggy/bambuddy/issues/630)) — Files added to a project from the archive were counted in project statistics (completed prints, parts progress) as if they had already been printed. Only files with `status="completed"` (actually printed via a printer) now count toward completion stats. Files with `status="archived"` (stored but not yet printed) are no longer included. Reported by @SebSeifert.
- **Python 3.10 Compatibility** — Bambuddy failed to start on Python 3.10 with `ImportError: cannot import name 'StrEnum' from 'enum'` because `enum.StrEnum` was added in Python 3.11. Added a compatibility shim that falls back to `(str, Enum)` on Python < 3.11, matching the documented requirement of Python 3.10+.
- **Bug Report Bubble Overlapping Toasts** — Moved toast notifications and upload progress up so they stack above the bug report bubble instead of overlapping on top of each other.
- **Virtual Printer: Bind-TLS Proxy Handshake Failure on OpenSSL 3.x** — The TLS proxy connecting to the printer's bind port (3002) failed with `SSLV3_ALERT_HANDSHAKE_FAILURE` on systems with OpenSSL 3.x (e.g. Python 3.12+) because the default cipher set excludes plain RSA key exchange, which is the only mode Bambu printers support. Added `AES256-GCM-SHA384` and `AES128-GCM-SHA256` to the client SSL context's cipher list.
- **Windows: Server Shuts Down After 60 Seconds** ([#605](https://github.com/maziggy/bambuddy/issues/605)) — On Windows, terminating orphaned ffmpeg camera processes broadcast `CTRL_C_EVENT` to the entire process group, causing uvicorn to interpret it as a user-initiated shutdown. ffmpeg is now spawned in its own process group (`CREATE_NEW_PROCESS_GROUP`) so cleanup no longer affects the server. Reported by @Reactantvr.
- **Multi-Printer Filament Mapping Shows Wrong Nozzle Filaments on Dual-Nozzle Printers** ([#624](https://github.com/maziggy/bambuddy/issues/624)) — When selecting multiple printers for a print job on dual-nozzle printers (H2D), the per-printer filament mapping override dropdown showed filaments from both nozzles instead of only the correct nozzle for each slot. The single-printer filament mapping (FilamentMapping.tsx) was fixed in v0.2.1 to filter by `nozzle_id`, but the multi-printer path (InlineMappingEditor in PrinterSelector.tsx) was missed. Both the auto-match logic and the dropdown options now filter by `nozzle_id`, matching the single-printer behavior. Reported by @cadtoolbox.
- **Filament Mapping Dropdowns Missing Subtypes** ([#624](https://github.com/maziggy/bambuddy/issues/624)) — All filament mapping dropdowns (single-printer, multi-printer, and "Print to Any" model-based assignment) showed only the base material type (e.g., "PLA") without the subtype (e.g., "PLA Basic", "PLA Matte"). This made it impossible to distinguish between different filament variants of the same color. Now shows `tray_sub_brands` (e.g., "PLA Basic", "PLA Matte", "PETG HF") in all filament dropdowns, falling back to the base type when no subtype is set. The backend's available-filaments endpoint also includes `tray_sub_brands` in the dedup key, so "PLA Basic Black" and "PLA Matte Black" appear as separate entries instead of collapsing into duplicate "PLA (Black)" rows. Reported by @cadtoolbox.
- **Archive Card Shows "Source" Badge for Sliced .3mf Files** — Archive cards created from prints showed a "SOURCE" badge instead of "GCODE" when the filename was a plain `.3mf` (without `.gcode` in the name). The `isSlicedFile()` check only matched `.gcode` or `.gcode.3mf` extensions, but `.3mf` files can be either sliced (contains gcode) or raw source models. Now checks the archive's `total_layers` and `print_time_seconds` metadata — if either is present, the file is sliced. Also passes the original human-readable filename when creating archives from the file manager print flow (previously stored the UUID library filename).
- **AMS Slot Shows Wrong Material for "Support for" Profiles** — Configuring an AMS slot with a filament profile like "PLA Support for PETG PETG Basic @Bambu Lab H2D 0.4 nozzle" set the slot material to PLA instead of PETG. The name parser iterated material types in order and returned the first match ("PLA"), ignoring that "PLA Support for PETG" means the filament type is PETG. Both the frontend `parsePresetName()` and backend `_parse_material_from_name()` now detect the "X Support for Y" naming pattern and extract the material after "Support for". The frontend also prefers the corrected parsed material over the stored `filament_type` (which may have been saved with the old parser during import).
- **Firmware Check Shows Wrong Version for H2D Pro** ([#584](https://github.com/maziggy/bambuddy/issues/584)) — H2D Pro printers showed firmware as out of date because the firmware check matched against the H2D firmware track instead of the H2D Pro track. The firmware check's model-to-API-key mapping only had display names (e.g., "H2D", "H2D Pro") but not SSDP device codes (e.g., "O1E", "O2D"). Added all known SSDP model codes to the firmware check mapping so raw device codes resolve to the correct firmware track.
- **Spurious Error Notifications During Normal Printing (0300_0002)** — Some firmware versions send non-zero `print_error` values in MQTT during normal printing (e.g., `0x03000002` → short code `0300_0002`). The `print_error` parser treated any non-zero value as a real error, appending it to `hms_errors` and triggering notifications — even though the printer was printing fine. All known real HMS error codes have their low 16 bits >= `0x4000` (`0x4xxx` = fatal, `0x8xxx` = warning/pause, `0xCxxx` = prompt). Values below `0x4000` are status/phase indicators, not faults. Now skips values where the error portion is below `0x4000` in both the `print_error` and `hms` array parsers.
- **Spool Auto-Assign Fails With Greenlet Error** ([#612](https://github.com/maziggy/bambuddy/issues/612)) — RFID spool auto-assignment logged `WARNING greenlet_spawn has not been called; can't call await_only() here` and silently failed. The `Spool.assignments` relationship was never eagerly loaded: when `auto_assign_spool()` created a new `SpoolAssignment` and called `db.add()`, SQLAlchemy resolved the FK back-populates synchronously (outside the async greenlet), triggering a lazy load on the uninitialized `spool.assignments` collection. The previous fix only covered `spool.k_profiles`. Now also initializes `spool.assignments = []` on newly created spools in `create_spool_from_tray()`, and adds `selectinload(Spool.assignments)` to both queries in `get_spool_by_tag()` for existing spools. Added `exc_info=True` to the error handlers for full tracebacks in future logs.
- **SpoolBuddy Link Tag Missing tag_type** — Linking an NFC tag to a spool via the SpoolBuddy dashboard's "Link to Spool" action only set `tag_uid` but left `tag_type` and `data_origin` empty, because it called the generic `updateSpool` API instead of the dedicated `linkTagToSpool` endpoint. The printer card's `LinkSpoolModal` already used `linkTagToSpool` correctly. Now uses `linkTagToSpool` with `tag_type: 'generic'` and `data_origin: 'nfc_link'`, which also handles conflict checks and archived tag recycling.
- **SpoolBuddy AMS Page Missing Fill Levels for Non-BL Spools** — AMS slots with non-Bambu Lab spools assigned to inventory didn't show fill level bars on the SpoolBuddy AMS page, even though the main printer card displayed them correctly. The SpoolBuddy AMS page only used the MQTT `remain` field (which is -1/unknown for non-BL spools), while the printer card had a fallback chain: Spoolman → inventory → AMS remain. Now fetches inventory spool assignments and computes fill levels from `(label_weight - weight_used) / label_weight`, falling back to AMS remain when no inventory assignment exists.
- **SpoolBuddy AMS Page Ext-R Slot Falsely Shown as Active When Idle** — On dual-nozzle printers (H2D), the Ext-R slot was incorrectly highlighted as active when the printer was idle. The ext-R tray has `id=255`, and the idle sentinel `tray_now=255` matched it via `trayNow === extTrayId`. The main printer card avoided this by clearing `effectiveTrayNow` to `undefined` when `tray_now=255`. Now guards against `tray_now=255` before any ext slot active check.
- **Printer Card Loses Info When Print Is Paused** ([#562](https://github.com/maziggy/bambuddy/issues/562)) — When a print was paused (via G-code pause command or user action), the printer card showed the print as finished — the progress bar, print name, ETA, layer count, and cover image all disappeared, replaced by the idle "Ready to Print" placeholder. The display conditions only checked for `state === 'RUNNING'` but not `'PAUSE'`, even though other parts of the same page (Skip Objects button, Stop/Resume controls) already handled both states correctly. Now shows print progress info for both `RUNNING` and `PAUSE` states, and the status label correctly reads "Paused" instead of the hardcoded "Printing" fallback.
- **SpoolBuddy "Assign to AMS" Slot Shows Empty Fields in Slicer** — After assigning a spool to an AMS slot via SpoolBuddy's "Assign to AMS" button, the slicer's slot overview showed the correct filament, but opening the slot detail card showed all fields empty/unselected. Two bugs: (1) the `assign_spool` backend called the cloud API with the raw `slicer_filament` value including its version suffix (e.g., `PFUS9ac902733670a9_07`), which returned a 404; the silent fallback sent the `setting_id` as `tray_info_idx` instead of the real `filament_id` (e.g., `PFUS9ac902733670a9` instead of `P4d64437`), and the slicer couldn't resolve the preset; (2) no `SlotPresetMapping` was saved, so Bambuddy's own ConfigureAmsSlotModal couldn't identify the active preset when reopened. Now strips version suffixes before the cloud lookup, resolves the real `filament_id` via the cloud API (with local preset and generic ID fallbacks), includes the brand name in `tray_sub_brands`, and saves the slot preset mapping from the frontend after assignment.
- **Virtual Printer Bind Server Fails With TLS-Enabled Slicers** ([#559](https://github.com/maziggy/bambuddy/issues/559)) — BambuStudio uses TLS on port 3002 for certain printer models (e.g. A1 Mini / N1), but the bind server only spoke plain TCP on both ports 3000 and 3002. The slicer's TLS ClientHello was rejected as an "invalid frame", preventing discovery and connection entirely. Port 3002 now uses TLS (using the VP's existing certificate), while port 3000 remains plain TCP for backwards compatibility. The proxy-mode bind proxy was also updated to use TLS termination on port 3002.
- **Queue Returns 500 When Cancelled Print Exists** ([#558](https://github.com/maziggy/bambuddy/issues/558)) — When a print was cancelled mid-print, the MQTT completion handler stored status `"aborted"` on the queue item, but the response schema only accepts `"pending"`, `"printing"`, `"completed"`, `"failed"`, `"skipped"`, or `"cancelled"`. Listing all queue items hit a Pydantic validation error on the invalid status, returning a 500 error. Filtering by a specific status (e.g. "pending") excluded the bad row and worked fine. Now normalises `"aborted"` to `"cancelled"` before storing. A startup fixup also converts any existing `"aborted"` rows.
- **Tests Send Real Maintenance Notifications** — Tests that call `on_print_complete(status="completed")` created background `asyncio` tasks (maintenance check, smart plug, notifications) that outlived the test's mock context. When the event loop processed these orphaned tasks, `async_session` was no longer patched and they queried the real production database — finding real printers with maintenance due and real notification providers, then sending real notifications. Tests now cancel spawned background tasks before the mock context exits.
- **Virtual Printer Config Changes Ignored Until Toggle Off/On** — Changing a virtual printer's mode (e.g. proxy → archive), model, access code, bind IP, remote interface IP, or target printer via the UI updated the database but the running VP instance was never restarted. `sync_from_db()` skipped any VP whose ID was already in the running instances dict without checking if config had changed. Now compares critical fields between the running instance and DB record and restarts the VP when a difference is detected.
- **Sidebar Navigation Ignores User Permissions** — All sidebar navigation items (Archives, Queue, Stats, Profiles, Maintenance, Projects, Inventory, Files) were visible to every user regardless of their role's permissions. Only the Settings item was permission-gated. Now each nav item is hidden when the user lacks the corresponding read permission (e.g., `archives:read`, `queue:read`, `library:read`). The Printers item remains always visible as the home page. Also added the missing `inventory:read|create|update|delete` permissions to the frontend Permission type (they existed in the backend but were absent from the frontend type definition).
- **Camera Button Clickable Without Permission & ffmpeg Process Leak** ([#550](https://github.com/maziggy/bambuddy/issues/550)) — Two camera issues in multi-user environments (e.g., classrooms with multiple printers). First, the camera button on the printer card was clickable even when the user's role lacked `camera:view` permission. Now disabled with a permission tooltip, matching the existing pattern for `printers:control` on the chamber light button. Second, ffmpeg processes (~240MB each) were never cleaned up after closing a camera stream. The `stop_camera_stream` endpoint called `terminate()` but never `wait()`ed or `kill()`ed, and HTTP disconnect detection in the streaming response only checked between frames — if the generator was blocked reading from ffmpeg stdout, disconnect was never detected (due to TCP send buffer masking the closed connection). Three fixes: (1) the stop endpoint now uses `terminate()` → `wait(2s)` → `kill()` → `wait()`; (2) each stream gets a background disconnect monitor task that polls `request.is_disconnected()` every 2 seconds independently of the frame loop, directly killing the ffmpeg process on disconnect; (3) a periodic cleanup (every 60s) scans `/proc` for any ffmpeg process with a Bambu RTSP URL (`rtsps://bblp:`) that isn't in an active stream and `SIGKILL`s it — catching orphans that survive app restarts or generator abandonment.
- **Windows Install Fails With "Syntax of the Command Is Incorrect"** ([#544](https://github.com/maziggy/bambuddy/issues/544)) — The `start_bambuddy.bat` Python hash verification used a multi-line `for /f "usebackq"` with a backtick-delimited command split across lines. Windows CMD cannot parse line breaks inside backtick-delimited `for /f` commands, causing "The syntax of the command is incorrect" immediately after downloading Python. The entire block was also redundant — it downloaded a separate checksum file from python.org and re-verified the hash, but `verify_sha256` had already checked the archive against the pinned hash on the previous line. Removed the duplicate verification block. Also had a secondary bug: always downloaded the `amd64` checksum even on `arm64` systems.
- **Queue Badge Shows on Incompatible Printers** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — The purple queue counter badge in the printer card header showed on all printers of the same model when a job was scheduled for "any [model]", even if the printer didn't have the matching filament color loaded. The `PrinterQueueWidget` (which shows "Clear Plate & Start") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count.
- **SpoolBuddy Daemon Can't Find Hardware Drivers** — The daemon's `nfc_reader.py` and `scale_reader.py` import `read_tag` and `scale_diag` as bare modules, but these files live in `spoolbuddy/scripts/` which isn't on Python's module search path. The systemd service sets `WorkingDirectory` to `spoolbuddy/` and runs `python -m daemon.main`, so only the `spoolbuddy/` and `daemon/` directories are on `sys.path`. Added `scripts/` to `sys.path` at daemon startup, resolved relative to the module file so it works regardless of install path. Also moved the `read_tag` import inside `NFCReader.__init__`'s try/except block — it was previously outside, so a missing module crashed the entire daemon instead of gracefully skipping NFC polling. Demoted hardware-not-available log messages from ERROR to INFO since missing modules are expected when hardware isn't connected.
- **SpoolBuddy Scale Tare & Calibration Not Applied** — The SpoolBuddy scale tare and calibrate buttons on the Settings page queued commands but never executed them. Five bugs in the chain: (1) the daemon received the `tare` command via heartbeat but never called `scale.tare()` — a comment said "need cross-task communication" but the ScaleReader was already available in the shared dict; (2) no API endpoint existed for the daemon to report the new tare offset back to the backend database, so tare results were lost; (3) when calibration values changed in heartbeat responses, the daemon updated its config object but never called `scale.update_calibration()`, so the ScaleReader kept using its initial values forever; (4) the heartbeat response that delivered the tare command still contained pre-tare calibration values, which immediately overwrote the new tare offset back to zero; (5) the `set-factor` endpoint computed `calibration_factor` using the DB `tare_offset`, which could be stale or zero if the tare hadn't persisted yet — producing a wildly wrong factor (e.g., 5000g displayed with empty scale). Added a `POST /devices/{device_id}/calibration/set-tare` endpoint and `update_tare()` API client method. The heartbeat loop now executes `scale.tare()` when the tare command is received, persists the result via the new endpoint, propagates calibration changes to the ScaleReader instance, and skips calibration sync on the heartbeat cycle that delivers a tare command. The calibration flow now captures the raw ADC at tare time and sends it alongside the loaded-weight ADC in step 2, so the factor is computed from the actual tare reference rather than the DB value — making calibration self-contained and independent of the tare persistence round-trip. The calibration weight input uses a compact touch-friendly numpad since the RPi kiosk has no physical keyboard.
- **A1 Mini Shows "Unknown" Status After MQTT Payload Decode Failure** ([#549](https://github.com/maziggy/bambuddy/issues/549)) — Some printer firmware versions (observed on A1 Mini 01.07.02.00) occasionally send MQTT payloads containing non-UTF-8 bytes. The `_on_message` handler called `msg.payload.decode()` (strict UTF-8), and the resulting `UnicodeDecodeError` was not caught — only `json.JSONDecodeError` was handled. The entire message was silently dropped, causing printer status to show "unknown", temperatures to read 0°C, and AMS data to disappear. Now catches `UnicodeDecodeError` and falls back to `decode(errors="replace")`, which substitutes invalid bytes with U+FFFD while keeping the JSON structure intact. Logs a warning for diagnostics.
- **H2C Dual Nozzle Variant (O1C2) Not Recognized** ([#489](https://github.com/maziggy/bambuddy/issues/489)) — The H2C dual nozzle variant reports model code `O1C2` via MQTT, but only `O1C` was in the recognized model maps. This caused the camera to use the wrong protocol (chamber image on port 6000 instead of RTSP on port 322) — the printer immediately closed the connection, producing a reconnect loop. Also affected model display names, chamber temperature support detection, linear rail classification, and virtual printer model mapping. Added `O1C2` to all model ID maps across backend and frontend.
- **Support Package Leaks Full Subnet IPs and Misdetects Docker Network Mode** — Three support package fixes. First, the network section included full subnet addresses (e.g., `192.168.192.0/24`); now masks the first two octets (`x.x.192.0/24`). Second, `network_mode_hint` used `len(interfaces) > 2` which always reported "bridge" on single-NIC hosts even with `network_mode: host`, because `get_network_interfaces()` excludes Docker infrastructure interfaces. Now checks for the presence of Docker interfaces (`docker0`, `br-*`, `veth*`) via `socket.if_nameindex()` — these are only visible when the container shares the host network namespace. Third, `developer_mode` was still null for most users because the MQTT `fun` field was only parsed inside the `print` key; some firmware versions send it at the top level of the payload. Now also checks top-level `fun`. Also added a `virtual_printers` section with mode, model, enabled/running status, and pending file count for each configured virtual printer.
- **SpoolBuddy Scale Calibration Lost After Reboot** — The SpoolBuddy daemon generated its device ID from the MAC address of whichever network interface `Path.iterdir()` returned first, but filesystem iteration order is non-deterministic. On different boots, the daemon could pick `eth0` (MAC ending `3100`) or `wlan0` (MAC ending `3102`), producing a different `device_id` each time. Since calibration values (`tare_offset`, `calibration_factor`) are stored per device ID in the backend database, a new ID meant registering as a brand-new uncalibrated device. Fixed by sorting network interfaces alphabetically before selection, ensuring the same interface (and thus the same device ID) is always chosen.
- **SpoolBuddy NFC Reader Fails to Detect Tags** — The PN5180 NFC reader had two polling issues. First, each `activate_type_a()` call that returned `None` (no tag) corrupted the PN5180 transceive state — subsequent calls silently failed even when a tag was physically present, making it impossible to detect tags placed after startup (only tags already on the reader during init were detected). Fixed by performing a full hardware reset (RST pin toggle + RF re-init, ~240ms) before every idle poll, giving a ~1.8 Hz effective poll rate. Second, after a successful SELECT the card stayed in ACTIVE state and ignored subsequent WUPA/REQA, causing false "tag removed" events after ~1 second. Fixed with a light RF off/on cycle (13ms) before each poll when a tag is present, resetting the card to IDLE for re-selection. Also added error-based auto-recovery (full hardware reset after 10 consecutive poll exceptions), periodic status logging every 60 seconds, and accurate heartbeat reporting of NFC/scale health.

### Changed
- **CI: Node.js 20 → 22** — Updated GitHub Actions workflows (`ci.yml`, `security.yml`) from Node.js 20 to Node.js 22 LTS ahead of [GitHub's Node 20 deprecation](https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/).
- **Daily Builds Falsely Trigger Update Notification** — The version parser misclassified daily build tags (e.g. `0.2.2b4-daily.20260313`) as full releases instead of betas, because the `-daily.YYYYMMDD` suffix pushed the last dot-segment to a pure number (`20260313`), bypassing the prerelease detection. Users running the same beta version saw a spurious "update available" notification after each daily build. Now strips the daily suffix before parsing.
- **License changed from MIT to AGPL-3.0** — To prevent unauthorized redistribution of Bambuddy as a closed-source product. All existing contributions were made under MIT, which is forward-compatible with AGPL-3.0. Community contributions and usage are unaffected.
- **License changed from MIT to AGPL-3.0** — To prevent unauthorized redistribution of Bambuddy as a closed-source product. All existing contributions were made under MIT, which is forward-compatible with AGPL-3.0. Community contributions and usage are unaffected.

### Improved
- **Shorter Inventory Location Labels** — The location column in the Inventory table now shows compact labels like "H2D-1 B3" instead of "H2D-1 AMS-B Slot 3". External spool holders show "Ext" instead of "External". AMS-HT labels remain unchanged ("HT-A").
- **Higher FTP Timeout Options for Large Files** ([#660](https://github.com/maziggy/bambuddy/issues/660)) — Added 180s and 300s FTP timeout options in Settings. The previous maximum of 120s was insufficient for large 3MF files (e.g. 28 MB Hueforge models) which can't be downloaded from the printer's FTP server within 2 minutes, especially during active printing. Reported by @PasDoe.
- **Separate Permission for AMS Spool Assignments** ([#635](https://github.com/maziggy/bambuddy/issues/635)) — Added a new `inventory:view_assignments` permission that controls whether spool-to-AMS-slot assignment data is visible on the Printers page. Previously, viewing spool assignments on printer cards required `inventory:read`, which also exposed the full Inventory page in the sidebar. Admins can now grant `inventory:view_assignments` without `inventory:read` so users can see what's loaded in the AMS without accessing the full spool inventory. All default groups (Administrators, Operators, Viewers) include the new permission automatically. Also fixed multi-word permission labels in the group editor (e.g. "Update_Own" → "Update Own"). Reported by @Minebuddy.
- **Prometheus Build Info Metric** ([#633](https://github.com/maziggy/bambuddy/pull/633)) — Added a `bambuddy_build_info` gauge metric to the Prometheus metrics endpoint, exposing the application version, Python version, platform, and architecture as labels. Follows the standard Prometheus `_build_info` convention for dashboards and version-change alerting. Contributed by @sw1nn.
- **i18n: Settings, Smart Plugs, Notifications, Backup/Restore** — Replaced all hardcoded English strings with translation keys (`t()` calls) across the Settings page, Smart Plug components (SmartPlugCard, AddSmartPlugModal, SwitchbarPopover), Notification components (NotificationProviderCard, AddNotificationModal, NotificationTemplateEditor, NotificationLogViewer), and Backup/Restore components (GitHubBackupSettings, RestoreModal). Added ~600 new translation keys to all 7 supported locales (en, de, ja, fr, it, pt-BR, zh-CN). Removed hardcoded label maps (`PROVIDER_LABELS`, `EVENT_LABELS`, `CATEGORY_LABELS`) in favor of dynamic translation key lookups with fallbacks.
- **Install Script: Branch Selection** — The native install script (`install.sh`) now supports a `--branch` option and an interactive branch prompt (defaults to `main`). Previously the script hardcoded `origin/main`, so beta testers told to install from a beta branch would silently get the stable release instead. Fresh installs use `git clone --branch`, existing installs checkout and reset to the selected branch. The install summary highlights non-main branches in yellow with a "(beta)" label. Invalid branch names are caught early with an error message listing available branches.
- **Print Queue Scheduler Diagnostics** ([#616](https://github.com/maziggy/bambuddy/issues/616)) — Added diagnostic logging to the print queue scheduler to help diagnose why queued prints aren't starting. After each queue check, the scheduler now logs a skip summary (how many items were skipped due to manual_start, scheduled_time, etc.) and for each busy printer, logs the exact state preventing it from being considered idle (connected status, printer state, plate_cleared flag). Previously the scheduler only logged "found N pending items" with no visibility into why items were skipped.
- **SpoolBuddy Settings Page Redesign** — Redesigned the SpoolBuddy settings page with a tabbed layout (Device, Display, Scale, Updates). The Device tab shows an About section, NFC reader info (type, connection, status), device info (host, IP, uptime, online status), and device ID. The Display tab has a brightness slider (CSS software filter for HDMI displays) and screen blank timeout selector (Off, 1m, 2m, 5m, 10m, 30m) — the screen blanks after user inactivity (no touch) and wakes on tap. The Scale tab shows live weight with a step-indicator calibration wizard (tare → place known weight → calibrate). The Updates tab shows the daemon version and checks for updates against GitHub releases with optional beta inclusion. Display settings (brightness + blank timeout) are stored per-device in the backend and applied instantly in the frontend layout via outlet context.
- **SpoolBuddy Language & Time Format Support** — The SpoolBuddy kiosk now respects Bambuddy's configured UI language and time format. Added a `language` field to backend app settings so the UI language is persisted server-side (previously only stored in browser localStorage, inaccessible to the kiosk's separate Chromium instance). The SpoolBuddy layout fetches settings on load and syncs `i18n.changeLanguage()`. The top bar clock uses `formatTimeOnly()` with the user's time format setting (system/12h/24h). Added full SpoolBuddy settings translations for all 6 supported languages (English, German, French, Japanese, Italian, Portuguese).
- **SpoolBuddy Kiosk Stability** — Disabled Chromium's swipe-to-navigate gesture (`--overscroll-history-navigation=0`) in the install script to prevent accidental back-navigation on the touchscreen. Added the `video` group to the SpoolBuddy system user for DSI backlight access.
- **SpoolBuddy Touch-Friendly UI** — Enlarged all interactive elements across the SpoolBuddy kiosk UI for comfortable finger use on the 1024×600 RPi touchscreen. Bottom nav icons and labels increased (20→24px icons, 10→12px labels, 48→56px bar height). Top bar printer selector and clock enlarged. Dashboard stats bar compacted, printers card removed (printer selection via top bar is sufficient), section headers and device status text bumped up. AMS page single-slot cards, spool visualizations, and fill bars enlarged. AMS unit cards get larger spool previews (56→64px), bigger material/slot text, and larger humidity/temperature indicators. Inventory spool cards, settings page headers, and calibration inputs all sized up to meet 44px minimum tap targets. The AMS slot configuration modal now renders in a two-column full-screen layout on the kiosk display (filament list on left, K-profile and color picker on right) instead of the standard centered dialog, eliminating scrolling.
- **Ethernet Connection Indicator** ([#585](https://github.com/maziggy/bambuddy/issues/585)) — Printers connected via ethernet now show a green "Ethernet" badge with a cable icon instead of the WiFi signal strength indicator. Detected via `home_flag` bit 18 from the printer's MQTT data. The printer info modal also shows "Ethernet" instead of WiFi signal details.
- **SpoolBuddy AMS Page Single-Slot Card Layout** — AMS-HT and external spool cards on the SpoolBuddy AMS page now use a responsive grid (2 cards per AMS card width) instead of auto-sized flex items, so they align with the regular AMS card columns above. Regular AMS cards no longer stretch vertically to fill available space on printers with fewer AMS units.
- **SpoolBuddy Scale Value Stabilization** — The SpoolBuddy daemon now suppresses redundant scale weight reports: only sends updates when the weight changes by ≥2g. Previously every 1-second report interval sent a reading regardless of change, and stability state flips (stable ↔ unstable) also triggered reports — when ADC noise kept the spread hovering around the 2g stability threshold, the flag toggled every cycle, forcing a report with a slightly different weight each time. Removed stability flipping as a report trigger (the stable flag is still included in each report for consumers). Also increased the NAU7802 moving average window from 5 to 20 samples (500ms → 2s) to smooth ADC noise. The frontend also applies a 3g display threshold as defense-in-depth.
- **SpoolBuddy TopBar: Online Printer Selection** — The printer selector in the SpoolBuddy top bar now only shows online printers and auto-selects the first online printer. If the currently selected printer goes offline, it automatically switches to the next available online printer. Also replaced the placeholder icon with the SpoolBuddy logo. Renamed the connection status label from "Online" to "Backend" for clarity.
- **SpoolBuddy Assign to AMS Redesign** — The "Assign to AMS" sub-modal (opened from the spool card) is now a full-screen overlay that reuses the `AmsUnitCard` component from the AMS page. Regular AMS units display in a 2-column grid with the same spool visualization, fill bars, and material labels. AMS-HT and external slots (Ext / Ext-L / Ext-R on dual-nozzle printers) appear in a compact horizontal row below. Clicking any slot auto-configures the filament via a single `assignSpool` API call — the backend handles both the DB assignment and MQTT configuration. The printer selector was removed from the modal since the top bar already provides printer selection. Dual-nozzle printers show L/R nozzle badges on each AMS unit.
- **Filament ID Conversion Utility** — Extracted filament_id ↔ setting_id conversion logic into a shared utility (`backend/app/utils/filament_ids.py`). The `assign_spool` endpoint now normalizes `slicer_filament` (which can be stored in either filament_id format like "GFL05" or setting_id format like "GFSL05_07") into the correct `tray_info_idx` and `setting_id` for the MQTT command. Previously `setting_id` was always sent as empty string, which could cause BambuStudio to not resolve the filament preset for the AMS slot.
- **Updates Card Separates Firmware and Software Settings** — The Updates card on the Settings page mixed printer firmware and Bambuddy software update toggles with no visual grouping. Now splits the card into two labeled sections ("Printer Firmware" and "Bambuddy Software") separated by a divider, making it clear which toggles control what.
- **SpoolBuddy Test Coverage** — Added integration tests for all 12 SpoolBuddy API endpoints (21 backend tests covering device registration/re-registration, heartbeat status and pending commands, NFC tag scan/match/removal, scale reading broadcast, spool weight calculation, and scale calibration including tare, set-factor, and zero-delta error handling) and component tests for the three main SpoolBuddy frontend components (20 frontend tests covering WeightDisplay weight formatting and status indicators, SpoolInfoCard spool info rendering and action callbacks, UnknownTagCard tag display, and TagDetectedModal open/close/escape behavior with known and unknown spool views).
- **Cleanup Obsolete Settings** — The startup migration now deletes orphaned settings keys from the database that are no longer used by the application (e.g., `slicer_binary_path` from earlier slicer integration research).
- **Added HUF Currency** ([#579](https://github.com/maziggy/bambuddy/issues/579)) — Added Hungarian Forint (HUF, Ft) to the supported currencies list for filament cost tracking.
- **FTP Upload Progress & Speed** — Reduced FTP upload chunk size from 1MB to 64KB for smoother progress reporting — at typical printer FTP speeds (~50-100KB/s) the progress bar now updates roughly every second instead of appearing stuck for 20+ seconds between jumps. Removed the post-upload `voidresp()` wait for all printer models (previously only skipped for A1); H2D printers delay the FTP 226 acknowledgment by 30+ seconds after data transfer completes, causing a long hang at 100%. The data is already on the SD card once the transfer finishes. Also added transfer speed logging (KB/s) and PASV+TLS handshake timing to help diagnose slow connections.
- **Wider Print & Schedule Modals** — Increased the Print and Schedule Print modal width from 512px to 672px to better accommodate long filament profile names (e.g., "PLA Support for PETG PETG Basic @Bambu Lab H2D 0.4 nozzle").

### Security
- **Stored XSS via Project Notes** — Project notes were rendered with `dangerouslySetInnerHTML` without sanitization, allowing injected `<script>` or event handler payloads to execute in any viewer's browser and steal JWT tokens from localStorage. Now sanitized with DOMPurify before rendering.
- **Stored XSS via 3MF Description (Sanitizer Bypass)** — The hand-rolled HTML sanitizer in the Project Page modal reconstructed `<a>` tags by interpolating the `href` attribute without escaping embedded quotes. A crafted 3MF file with a single-quoted `href` containing a double-quote break-out could inject `onmouseover` event handlers through the sanitizer. Replaced the custom sanitizer with DOMPurify.
- **Unauthenticated Auth Toggle via Setup Endpoint** — The `/api/v1/auth/setup` endpoint could be called without authentication even when auth was already enabled, allowing any network client to disable authentication entirely. Now returns 403 when auth is already enabled; use the authenticated admin panel to modify auth settings.
- **PyJWT ≥2.12.0** — Bumped minimum version to address CVE-2026-32597.
- **flatted ≥3.4.0** — Updated transitive ESLint dependency to address GHSA-25h7-pfq9-p65f (unbounded recursion DoS).
- **Access Code Redacted from Support Logs** — Printer access codes embedded in RTSP stream URLs were not redacted in support bundles and bug report logs. Extended the URL credential sanitizer to cover `rtsps://` URLs and added access codes to the sensitive string collection for exact-match redaction.

## [0.2.2b3] - 2026-03-12

### New Features
- **Home Assistant Notification Provider** ([#656](https://github.com/maziggy/bambuddy/issues/656)) — Added Home Assistant as a notification provider. When HA is configured in Settings → Network → Home Assistant, selecting "Home Assistant" as a notification provider sends persistent notifications to the HA dashboard — no additional configuration needed. From there, HA automations can forward notifications to mobile apps, WhatsApp, or any other service. Requested by @TravisWilder.
- **Virtual Printer Queue Auto-Dispatch Toggle** ([#587](https://github.com/maziggy/bambuddy/issues/587)) — Added an "Auto-dispatch" toggle to virtual printers in Queue mode. When enabled (default), prints sent from the slicer are added to the queue and start automatically on the assigned printer — matching the current behavior. When disabled, prints are added to the queue with `manual_start` set, so they wait for manual dispatch. This allows users who want to review and manually assign prints before they start. Requested by @Percy2Live.
- **Queue All Plates** ([#530](https://github.com/maziggy/bambuddy/issues/530)) — Multi-plate 3MF files can now be queued in one action. When adding a multi-plate file to the queue, a "Queue All N Plates" toggle appears in the plate selector. When activated, every plate is added as a separate queue entry (one per plate × per selected printer), each individually editable from the queue page. The toggle is only available in add-to-queue mode (not reprint or edit). Requested by @Dendrowen.
- **Malaysian Ringgit Currency** ([#634](https://github.com/maziggy/bambuddy/issues/634)) — Added MYR (RM) to the list of supported currencies for filament cost tracking. Requested by @cynogen127.
- **ETA Variable in Notifications** ([#638](https://github.com/maziggy/bambuddy/issues/638)) — Added `{eta}` template variable to print start, print progress, and queue job started notifications. Shows the estimated wall-clock completion time (e.g. "15:53" or "3:53 PM") based on the user's configured time format (12h/24h). Existing `{estimated_time}` still shows duration ("1h 23m"). Requested by @SebSeifert.
- **Bulk Delete Spool and Color Catalog Entries** ([#646](https://github.com/maziggy/bambuddy/issues/646)) — Added checkbox selection and bulk delete to both the Spool Catalog and Color Catalog in Settings > Filament. Select individual entries with checkboxes, use the header checkbox to select/deselect all visible entries, then click "Delete Selected" to remove them in one operation. Previously, entries could only be deleted one at a time. Requested by @SebSeifert.
- **Force Color Match** ([#625](https://github.com/maziggy/bambuddy/pull/625)) — Added a "Force Color Match" option for "Print to Any" queue scheduling. When enabled, the scheduler requires a strict color match when assigning prints to printers, preventing incorrect filament assignments when multiple candidates are close in color. Prints wait in the queue until a printer with the exact matching filament is available. Contributed by @cadtoolbox.
- **Israeli New Shekel Currency** — Added ILS (₪) to the list of supported currencies for filament cost tracking.

### Changes
- **License changed from MIT to AGPL-3.0** — To prevent unauthorized redistribution of Bambuddy as a closed-source product. All existing contributions were made under MIT, which is forward-compatible with AGPL-3.0. Community contributions and usage are unaffected.

### Improved
- **Shorter Inventory Location Labels** — The location column in the Inventory table now shows compact labels like "H2D-1 B3" instead of "H2D-1 AMS-B Slot 3". External spool holders show "Ext" instead of "External". AMS-HT labels remain unchanged ("HT-A").
- **Higher FTP Timeout Options for Large Files** ([#660](https://github.com/maziggy/bambuddy/issues/660)) — Added 180s and 300s FTP timeout options in Settings. The previous maximum of 120s was insufficient for large 3MF files (e.g. 28 MB Hueforge models) which can't be downloaded from the printer's FTP server within 2 minutes, especially during active printing. Reported by @PasDoe.
- **Separate Permission for AMS Spool Assignments** ([#635](https://github.com/maziggy/bambuddy/issues/635)) — Added a new `inventory:view_assignments` permission that controls whether spool-to-AMS-slot assignment data is visible on the Printers page. Previously, viewing spool assignments on printer cards required `inventory:read`, which also exposed the full Inventory page in the sidebar. Admins can now grant `inventory:view_assignments` without `inventory:read` so users can see what's loaded in the AMS without accessing the full spool inventory. All default groups (Administrators, Operators, Viewers) include the new permission automatically. Also fixed multi-word permission labels in the group editor (e.g. "Update_Own" → "Update Own"). Reported by @Minebuddy.
- **Prometheus Build Info Metric** ([#633](https://github.com/maziggy/bambuddy/pull/633)) — Added a `bambuddy_build_info` gauge metric to the Prometheus metrics endpoint, exposing the application version, Python version, platform, and architecture as labels. Follows the standard Prometheus `_build_info` convention for dashboards and version-change alerting. Contributed by @sw1nn.

### Fixed
- **Beta Updates Shown When Disabled** ([#731](https://github.com/maziggy/bambuddy/issues/731)) — Daily beta builds (e.g. `v0.2.3b1-daily.20260316`) were offered as updates even with "Include beta versions" toggled off. The version parser only checked the last dot-separated segment for prerelease markers, but daily build tags put the beta indicator (`b1`) earlier with a numeric date suffix as the last segment. Now checks the entire version string. Reported by @Teolhyn.
- **Debug Logging Endpoint 500 Error** — The `GET /api/v1/support/debug-logging` endpoint returned a 500 Internal Server Error when the database contained a timezone-aware timestamp written by a previous version. The duration calculation subtracted a timezone-aware datetime from a naive `datetime.now()`, raising `TypeError`. Now strips timezone info when reading the stored timestamp.
- **Bed Cooled Notification Never Fires** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — The bed cooldown monitor always timed out after 30 minutes without sending a notification. After print completion, P1S (and likely other models) sends partial MQTT status updates that don't include `bed_temper`, so the cached bed temperature stayed frozen at the end-of-print value and never dropped below the threshold. The monitor now sends periodic `pushall` commands to the printer to force fresh temperature data. Also added debug logging to the polling loop for future diagnostics.
- **Notification Provider Missing Event Toggles on Create** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — When creating a new notification provider, the `on_bed_cooled` toggle and all 7 queue event toggles (`on_queue_job_added`, `on_queue_job_assigned`, `on_queue_job_started`, `on_queue_job_waiting`, `on_queue_job_skipped`, `on_queue_job_failed`, `on_queue_completed`) were silently discarded. The create endpoint manually listed each field but omitted these 8 toggles, so they always defaulted to `false` regardless of user selection. Editing an existing provider worked correctly.
- **Clear Plate Prompt Shown for Staged Queue Items** — The "Clear Plate & Start Next" button on the printer card appeared when all pending queue items were staged (`manual_start`/Queue Only), even though the scheduler won't auto-start them. The clear plate prompt now only appears when there are auto-dispatchable items that the scheduler will actually start after the plate is cleared.
- **Ethernet Badge Shown on WiFi-Only Printers** ([#585](https://github.com/maziggy/bambuddy/issues/585)) — The printer card network badge always showed "Ethernet" even on printers without an ethernet port. WiFi-only models (A1, P1P, etc.) are now excluded via model-based gating. Reported by @cadtoolbox.
- **GitHub Backup Required Cloud Login** ([#655](https://github.com/maziggy/bambuddy/issues/655)) — The GitHub backup settings card was completely blocked behind Bambu Cloud authentication, showing "Bambu Cloud login required" even though the backup feature works without it (K-profiles and app settings don't need cloud). Removed the cloud auth gate so GitHub backup can be configured and used without Bambu Cloud. The "Cloud Profiles" checkbox is disabled with a hint when not logged in. Reported by @TravisWilder.
- **GitHub Backup Log Timestamps Off by 1 Hour** — Backup log timestamps in the history table were displayed in UTC instead of the user's local timezone. The local `formatDateTime` function didn't use `parseUTCDate`, so timezone-less timestamps from SQLite were interpreted as local time. Now uses the shared `parseUTCDate` utility for correct UTC-to-local conversion.
- **H2D AMS Units Shown on Wrong Nozzle** ([#659](https://github.com/maziggy/bambuddy/issues/659)) — On the H2D dual-nozzle printer, AMS units were displayed on the wrong nozzle (e.g. both AMS-HT and AMS2 Pro shown on the left nozzle instead of their correct assignments). Three interrelated bugs in the AMS `info` field parsing: (1) the field was parsed as decimal instead of hexadecimal (BambuStudio uses `std::stoull(str, nullptr, 16)`), (2) the extruder ID was extracted as a single bit instead of a 4-bit field, and (3) partial MQTT updates overwrote the full extruder map instead of merging. Now correctly hex-parses the `info` field, extracts the 4-bit extruder ID from bits 8-11, skips uninitialized AMS units (`0xE`), and merges partial updates into the existing map. Reported by @cadtoolbox.
- **SD Card Error After FTP Upload** ([#645](https://github.com/maziggy/bambuddy/issues/645)) — After printing one file, subsequent prints could fail with `0500-C010 "MicroSD Card read/write exception"` until Bambuddy was restarted. The FTP upload used `transfercmd()` for A1 compatibility but skipped reading the server's 226 "Transfer complete" response, leaving the SD card file write unconfirmed. The print command was sent via MQTT before the printer's FTP server had finished flushing the file to disk. Now waits for the 226 confirmation after each upload (with a 60-second timeout for slower models like H2D). Reported by @lanfi89, confirmed by @Bademeister89.
- **P2S Shows Carbon Rod Maintenance Tasks** ([#640](https://github.com/maziggy/bambuddy/issues/640)) — The P2S was incorrectly classified as a carbon rod printer, showing "Lubricate Carbon Rods" and "Clean Carbon Rods" maintenance tasks. The P2S uses hardened steel linear shafts, not carbon fiber rods. Added a new `steel_rod` motion system category and "Lubricate Steel Rods" / "Clean Steel Rods" maintenance tasks specific to the P2S. X1/P1 series continue to show carbon rod tasks; A1/H2 series continue to show linear rail tasks. Reported by @maziggy.
- **Dispatch Toast Stuck After Second Print** — The print dispatch progress toast ("Starting prints…") stayed visible forever after the second print dispatch in a session. The dedup guard (`lastDispatchSummaryRef`) that prevents duplicate completion toasts was never reset between batches, so every single-printer dispatch produced the same summary key (`"first-complete:1:0"`). The first print completed normally, but subsequent completions matched the stale ref and skipped creating the done toast — leaving the progress toast stuck in "Processing" state with no way to dismiss except a page reload. Now resets the dedup guard whenever the dispatch toast is dismissed (auto-dismiss timeout, cleanup events) and when a new batch starts.
- **Archive Card Buttons Overlapping at Narrow Widths** ([#641](https://github.com/maziggy/bambuddy/issues/641)) — The "Reprint" and "Schedule" buttons at the bottom of archive cards overlapped when the browser window was narrower than the card grid expected (e.g. snapped to half-screen on a 2K monitor). The button text labels used a viewport-based `sm:` breakpoint that didn't account for actual card width. Added `overflow-hidden` to the flex buttons and `truncate` to the text spans so labels clip cleanly with ellipsis instead of bleeding into adjacent buttons. Reported by rsocko@outlook.com, confirmed by @dsmitty166.
- **Debug Logging Banner Timer Shows Negative Time** — When enabling debug logging, the banner showed a negative duration (e.g. "-60m -59s") equal to the server's UTC offset. The `enabled_at` timestamp was stored using `datetime.now()` (local time, no timezone indicator), but the frontend interpreted it as UTC. Now stores and compares all debug logging timestamps in UTC.
- **Non-Bambu Lab Spools Can't Link/Unlink to Spoolman** ([#653](https://github.com/maziggy/bambuddy/pull/653)) — The "Link to Spoolman" button was not shown for non-Bambu Lab spools (which lack RFID tag UIDs). Now generates a fallback tag from the printer ID, AMS ID, and tray ID for spools without RFID identifiers. Also added an "Unlink from Spoolman" button for non-Bambu spools that are already linked. Contributed by @shrunbr.
- **Spoolman Location Not Updated on Link/Unlink** ([#669](https://github.com/maziggy/bambuddy/pull/669)) — Linking a spool to Spoolman did not set the spool's location field. Now sets the Spoolman location to the printer name, AMS name, and slot number (e.g. "P2S-1 - AMS-A 3") when linking, and clears it when unlinking. Contributed by @shrunbr.


## [0.2.2b2] - 2026-03-06

### New Features
- **AMS Info Card & Custom Labels** ([#570](https://github.com/maziggy/bambuddy/pull/570)) — Hovering an AMS label (e.g. "AMS-A") on the Printers page now shows a popover with serial number, firmware version, and an editable friendly name. Custom labels are stored by AMS serial number so they persist when the unit is moved to a different printer. Slot numbers are now displayed inside each filament color circle with auto-inverted contrast for readability. Labels also appear in the Inventory page's location column. Contributed by @cadtoolbox.

### Changes
- **License changed from MIT to AGPL-3.0** — To prevent unauthorized redistribution of Bambuddy as a closed-source product. All existing contributions were made under MIT, which is forward-compatible with AGPL-3.0. Community contributions and usage are unaffected.

### Improved
- **i18n: Settings, Smart Plugs, Notifications, Backup/Restore** — Replaced all hardcoded English strings with translation keys (`t()` calls) across the Settings page, Smart Plug components (SmartPlugCard, AddSmartPlugModal, SwitchbarPopover), Notification components (NotificationProviderCard, AddNotificationModal, NotificationTemplateEditor, NotificationLogViewer), and Backup/Restore components (GitHubBackupSettings, RestoreModal). Added ~600 new translation keys to all 7 supported locales (en, de, ja, fr, it, pt-BR, zh-CN). Removed hardcoded label maps (`PROVIDER_LABELS`, `EVENT_LABELS`, `CATEGORY_LABELS`) in favor of dynamic translation key lookups with fallbacks.
- **Install Script: Branch Selection** — The native install script (`install.sh`) now supports a `--branch` option and an interactive branch prompt (defaults to `main`). Previously the script hardcoded `origin/main`, so beta testers told to install from a beta branch would silently get the stable release instead. Fresh installs use `git clone --branch`, existing installs checkout and reset to the selected branch. The install summary highlights non-main branches in yellow with a "(beta)" label. Invalid branch names are caught early with an error message listing available branches.
- **Print Queue Scheduler Diagnostics** ([#616](https://github.com/maziggy/bambuddy/issues/616)) — Added diagnostic logging to the print queue scheduler to help diagnose why queued prints aren't starting. After each queue check, the scheduler now logs a skip summary (how many items were skipped due to manual_start, scheduled_time, etc.) and for each busy printer, logs the exact state preventing it from being considered idle (connected status, printer state, plate_cleared flag). Previously the scheduler only logged "found N pending items" with no visibility into why items were skipped.

### Fixed
- **Print Dispatch Toast Disappears Instantly on Fast Uploads** ([#615](https://github.com/maziggy/bambuddy/issues/615)) — When sending a print job, the notification popup disappeared instantly for small files or closed immediately when the progress bar reached 100% for larger files, giving no confirmation that the job was submitted. The dispatch toast now stays visible for 3 seconds after completion, showing a success message (e.g. "1 print started successfully") before auto-dismissing. For very fast uploads where the progress toast was never shown, a fresh confirmation toast is created instead. Reported by @aneopsy.
- **Print Modal Shows Busy Printers as Selectable** ([#622](https://github.com/maziggy/bambuddy/issues/622)) — When printing a file from the file manager, the print modal listed all printers including busy ones. Selecting a busy printer resulted in a failed send notification. The printer selector now fetches each printer's live status and shows a state badge (Idle, Printing, Paused, Preparing, Finished, Failed, Offline). In reprint mode, busy printers are grayed out and not selectable. "Select all" also skips busy printers. In queue mode, busy printers remain selectable since the job will wait. Reported by contact@aito3d.fr.
- **PWA Install Not Available in Chrome** ([#629](https://github.com/maziggy/bambuddy/issues/629)) — Chrome did not show the PWA install prompt because the manifest icons had incorrect dimensions (e.g. 190px wide declared as 192px) and the manifest was missing the `screenshots` entries required for Chrome's richer install UI. Resized all three icons (`android-chrome-192x192.png`, `android-chrome-512x512.png`, `apple-touch-icon.png`) to their declared sizes, split the discouraged `"any maskable"` purpose into a dedicated `"maskable"` entry, and added mobile and desktop screenshots to the manifest. Reported by @SebSeifert.
- **Project Statistics Count Archived Files as Printed** ([#630](https://github.com/maziggy/bambuddy/issues/630)) — Files added to a project from the archive were counted in project statistics (completed prints, parts progress) as if they had already been printed. Only files with `status="completed"` (actually printed via a printer) now count toward completion stats. Files with `status="archived"` (stored but not yet printed) are no longer included. Reported by @SebSeifert.
- **Python 3.10 Compatibility** — Bambuddy failed to start on Python 3.10 with `ImportError: cannot import name 'StrEnum' from 'enum'` because `enum.StrEnum` was added in Python 3.11. Added a compatibility shim that falls back to `(str, Enum)` on Python < 3.11, matching the documented requirement of Python 3.10+.
- **Bug Report Bubble Overlapping Toasts** — Moved toast notifications and upload progress up so they stack above the bug report bubble instead of overlapping on top of each other.
- **Virtual Printer: Bind-TLS Proxy Handshake Failure on OpenSSL 3.x** — The TLS proxy connecting to the printer's bind port (3002) failed with `SSLV3_ALERT_HANDSHAKE_FAILURE` on systems with OpenSSL 3.x (e.g. Python 3.12+) because the default cipher set excludes plain RSA key exchange, which is the only mode Bambu printers support. Added `AES256-GCM-SHA384` and `AES128-GCM-SHA256` to the client SSL context's cipher list.
- **Windows: Server Shuts Down After 60 Seconds** ([#605](https://github.com/maziggy/bambuddy/issues/605)) — On Windows, terminating orphaned ffmpeg camera processes broadcast `CTRL_C_EVENT` to the entire process group, causing uvicorn to interpret it as a user-initiated shutdown. ffmpeg is now spawned in its own process group (`CREATE_NEW_PROCESS_GROUP`) so cleanup no longer affects the server. Reported by @Reactantvr.
- **Multi-Printer Filament Mapping Shows Wrong Nozzle Filaments on Dual-Nozzle Printers** ([#624](https://github.com/maziggy/bambuddy/issues/624)) — When selecting multiple printers for a print job on dual-nozzle printers (H2D), the per-printer filament mapping override dropdown showed filaments from both nozzles instead of only the correct nozzle for each slot. The single-printer filament mapping (FilamentMapping.tsx) was fixed in v0.2.1 to filter by `nozzle_id`, but the multi-printer path (InlineMappingEditor in PrinterSelector.tsx) was missed. Both the auto-match logic and the dropdown options now filter by `nozzle_id`, matching the single-printer behavior. Reported by @cadtoolbox.
- **Filament Mapping Dropdowns Missing Subtypes** ([#624](https://github.com/maziggy/bambuddy/issues/624)) — All filament mapping dropdowns (single-printer, multi-printer, and "Print to Any" model-based assignment) showed only the base material type (e.g., "PLA") without the subtype (e.g., "PLA Basic", "PLA Matte"). This made it impossible to distinguish between different filament variants of the same color. Now shows `tray_sub_brands` (e.g., "PLA Basic", "PLA Matte", "PETG HF") in all filament dropdowns, falling back to the base type when no subtype is set. The backend's available-filaments endpoint also includes `tray_sub_brands` in the dedup key, so "PLA Basic Black" and "PLA Matte Black" appear as separate entries instead of collapsing into duplicate "PLA (Black)" rows. Reported by @cadtoolbox.

## [0.2.2b1] - 2026-03-03

### Improved
- **SpoolBuddy Settings Page Redesign** — Redesigned the SpoolBuddy settings page with a tabbed layout (Device, Display, Scale, Updates). The Device tab shows an About section, NFC reader info (type, connection, status), device info (host, IP, uptime, online status), and device ID. The Display tab has a brightness slider (CSS software filter for HDMI displays) and screen blank timeout selector (Off, 1m, 2m, 5m, 10m, 30m) — the screen blanks after user inactivity (no touch) and wakes on tap. The Scale tab shows live weight with a step-indicator calibration wizard (tare → place known weight → calibrate). The Updates tab shows the daemon version and checks for updates against GitHub releases with optional beta inclusion. Display settings (brightness + blank timeout) are stored per-device in the backend and applied instantly in the frontend layout via outlet context.
- **SpoolBuddy Language & Time Format Support** — The SpoolBuddy kiosk now respects Bambuddy's configured UI language and time format. Added a `language` field to backend app settings so the UI language is persisted server-side (previously only stored in browser localStorage, inaccessible to the kiosk's separate Chromium instance). The SpoolBuddy layout fetches settings on load and syncs `i18n.changeLanguage()`. The top bar clock uses `formatTimeOnly()` with the user's time format setting (system/12h/24h). Added full SpoolBuddy settings translations for all 6 supported languages (English, German, French, Japanese, Italian, Portuguese).
- **SpoolBuddy Kiosk Stability** — Disabled Chromium's swipe-to-navigate gesture (`--overscroll-history-navigation=0`) in the install script to prevent accidental back-navigation on the touchscreen. Added the `video` group to the SpoolBuddy system user for DSI backlight access.
- **SpoolBuddy Touch-Friendly UI** — Enlarged all interactive elements across the SpoolBuddy kiosk UI for comfortable finger use on the 1024×600 RPi touchscreen. Bottom nav icons and labels increased (20→24px icons, 10→12px labels, 48→56px bar height). Top bar printer selector and clock enlarged. Dashboard stats bar compacted, printers card removed (printer selection via top bar is sufficient), section headers and device status text bumped up. AMS page single-slot cards, spool visualizations, and fill bars enlarged. AMS unit cards get larger spool previews (56→64px), bigger material/slot text, and larger humidity/temperature indicators. Inventory spool cards, settings page headers, and calibration inputs all sized up to meet 44px minimum tap targets. The AMS slot configuration modal now renders in a two-column full-screen layout on the kiosk display (filament list on left, K-profile and color picker on right) instead of the standard centered dialog, eliminating scrolling.
- **Ethernet Connection Indicator** ([#585](https://github.com/maziggy/bambuddy/issues/585)) — Printers connected via ethernet now show a green "Ethernet" badge with a cable icon instead of the WiFi signal strength indicator. Detected via `home_flag` bit 18 from the printer's MQTT data. The printer info modal also shows "Ethernet" instead of WiFi signal details.

### New Features
- **In-App Bug Reporting** — A floating bug report button in the bottom-right corner lets users submit bug reports directly from the Bambuddy UI. Reports include a description, optional screenshot (upload, paste, or drag & drop with automatic JPEG compression), optional contact email, and automatically collected diagnostic data. On submit, the system temporarily enables debug logging, sends push_all to all connected printers, waits 30 seconds to collect fresh logs, then submits everything to a secure relay on bambuddy.cool which creates a GitHub issue with sanitized logs uploaded as a separate file. All sensitive data (printer names, serial numbers, IPs, credentials, email addresses) is redacted from logs before submission. The expandable data privacy notice details exactly what is and isn't collected. Translated into all 7 supported languages.
- **SpoolBuddy NFC Tag Writing (OpenTag3D)** — SpoolBuddy can now write NFC tags for third-party filament spools using the OpenTag3D format on NTAG213/215/216 stickers. A new "Write" page (`/spoolbuddy/write-tag`) in the kiosk UI provides three workflows: write a tag for an existing inventory spool (no tag linked yet), create a new spool and write in one flow, or replace a damaged tag (unlinks old, writes new). The left panel shows a searchable spool list or a compact creation form (material dropdown, color picker, brand, weight); the right panel shows real-time NFC status with tag detection, a spool summary, and the write button. The backend encodes spool data as a 133-byte OpenTag3D NDEF message (MIME type `application/opentag3d`, fits NTAG213's 144-byte capacity) containing material, color, brand, weight, temperature, and RGBA color data. The write command flows through the existing heartbeat polling mechanism — the frontend queues a write, the daemon picks it up on the next heartbeat, writes page-by-page with read-back verification via the PN5180's NTAG WRITE (0xA2) command, and reports success/failure via WebSocket. On success the tag UID is automatically linked to the spool with `data_origin=opentag3d`. Written tags are readable by any OpenTag3D-compatible reader including SpoolBuddy itself. Translations added for all 6 languages.
- **SpoolBuddy On-Screen Keyboard** — Added a virtual QWERTY keyboard for the SpoolBuddy kiosk UI (and login page) since the Raspberry Pi has no physical keyboard and system-level virtual keyboards (squeekboard, wvkbd) don't auto-show/hide in the labwc/Chromium kiosk environment. Uses `react-simple-keyboard` with a dark theme matching the bambu-dark/bambu-green palette. Auto-shows when any text/password/email input is focused, supports shift, caps lock, backspace, and email-friendly keys (@, .). Inputs with `data-vkb="false"` are excluded (e.g. SpoolBuddySettingsPage's own numpad). A two-phase close prevents ghost-click passthrough to elements underneath the keyboard.
- **SpoolBuddy Inline Spool Cards** — Placing an NFC-tagged spool on the SpoolBuddy reader now shows spool info directly in the dashboard's right panel instead of a separate modal overlay. Known spools display a SpoolIcon with color/brand/material, a large remaining-weight readout with fill bar, and a weight comparison grid, with action buttons for "Assign to AMS", "Sync Weight", and "Close". Unknown tags show the tag UID, scale weight, and offer "Add to Inventory" or "Link to Spool" actions. The card stays visible if the tag is removed (for continued interaction) and won't re-appear for the same tag after dismissal — but re-placing a tag after removal shows it again. The idle spool animation displays when no tag is detected.
- **SpoolBuddy AMS Page: External Slots & Slot Configuration** — The SpoolBuddy AMS page (`/spoolbuddy/ams`) now displays external spool slots (single nozzle: "Ext", dual nozzle: "Ext-L"/"Ext-R") and AMS-HT units in a compact horizontal row below the regular AMS grid, fitting within the 1024×600 kiosk display without scrolling. Clicking any AMS, AMS-HT, or external slot opens the `ConfigureAmsSlotModal` to configure filament type and color — the same modal used on the main Printers page. Dual-nozzle printers show L/R nozzle badges on each AMS unit. Temperature and humidity are displayed with threshold-colored SVG icons (green/gold/red) matching the Bambu Lab style on the main printer cards, using the configured AMS humidity and temperature thresholds from settings.
- **SpoolBuddy Dashboard Redesign** — Redesigned the SpoolBuddy dashboard with a two-column layout: left column shows device connection status (scale and NFC with state-colored icons — green when device is online, gray when offline) and printer status badges below (compact pills with green/gray dots for online/offline, wrapping to fit without scrolling); right column shows the current spool card. Cards use a dashed border style for a cleaner look. The large weight display card was removed in favor of the inline scale reading in the device card. Unknown NFC tags now offer a quick-add modal that creates a basic PLA spool entry linked to the tag — with a hint recommending users add spools via the main Bambuddy UI first for full details. The separate SpoolBuddy inventory page was removed since inventory management belongs in the main Bambuddy frontend; the bottom nav now has three tabs (Dashboard, AMS, Settings).
- **SpoolBuddy Kiosk Auth Bypass via API Key** — When Bambuddy auth is enabled, the SpoolBuddy kiosk (Chromium on RPi) was redirected to the login page because the `ProtectedRoute` requires a user object from `GET /auth/me`, which only accepted JWT tokens. The `/auth/me` endpoint now also accepts API keys (via `Authorization: Bearer bb_xxx` or `X-API-Key` header) and returns a synthetic admin user with all permissions. The frontend's `AuthContext` reads an optional `?token=` URL parameter on first load, stores it in localStorage, and strips it from the URL to prevent leakage via browser history or referrer. The install script now includes the API key in the kiosk URL (`/spoolbuddy?token=${API_KEY}`), so the device authenticates automatically on boot without manual login.
- **Daily Beta Builds** — Added a release script (`docker-publish-daily-beta.sh`) that reads the current `APP_VERSION` from config, builds a multi-arch Docker image, pushes to both GHCR and Docker Hub, and creates/updates a GitHub prerelease with changelog notes. Daily builds overwrite the same beta version tag (e.g., `0.2.2b1`) — users pull the latest by re-pulling the tag or using Watchtower. Beta images are never tagged as `latest`.
- **Inventory Scale Weight Check Column** — Added a "Weight Check" column (hidden by default) to the inventory table that compares each spool's last scale measurement against its calculated gross weight (net remaining + core weight). Spools within a ±50g tolerance show a green checkmark; mismatched spools show a yellow warning with the difference and a sync button that trusts the scale reading and resets weight tracking. The backend stores `last_scale_weight` and `last_weighed_at` on each spool whenever weight is synced via SpoolBuddy, and the column tooltip shows scale weight, calculated weight, and difference. Edge case: when scale weight is below core weight (empty spool or not on scale), the comparison treats it as a match since sync can't correct this.

### Fixed
- **Archive Card Shows "Source" Badge for Sliced .3mf Files** — Archive cards created from prints showed a "SOURCE" badge instead of "GCODE" when the filename was a plain `.3mf` (without `.gcode` in the name). The `isSlicedFile()` check only matched `.gcode` or `.gcode.3mf` extensions, but `.3mf` files can be either sliced (contains gcode) or raw source models. Now checks the archive's `total_layers` and `print_time_seconds` metadata — if either is present, the file is sliced. Also passes the original human-readable filename when creating archives from the file manager print flow (previously stored the UUID library filename).
- **AMS Slot Shows Wrong Material for "Support for" Profiles** — Configuring an AMS slot with a filament profile like "PLA Support for PETG PETG Basic @Bambu Lab H2D 0.4 nozzle" set the slot material to PLA instead of PETG. The name parser iterated material types in order and returned the first match ("PLA"), ignoring that "PLA Support for PETG" means the filament type is PETG. Both the frontend `parsePresetName()` and backend `_parse_material_from_name()` now detect the "X Support for Y" naming pattern and extract the material after "Support for". The frontend also prefers the corrected parsed material over the stored `filament_type` (which may have been saved with the old parser during import).
- **Firmware Check Shows Wrong Version for H2D Pro** ([#584](https://github.com/maziggy/bambuddy/issues/584)) — H2D Pro printers showed firmware as out of date because the firmware check matched against the H2D firmware track instead of the H2D Pro track. The firmware check's model-to-API-key mapping only had display names (e.g., "H2D", "H2D Pro") but not SSDP device codes (e.g., "O1E", "O2D"). Added all known SSDP model codes to the firmware check mapping so raw device codes resolve to the correct firmware track.
- **Spurious Error Notifications During Normal Printing (0300_0002)** — Some firmware versions send non-zero `print_error` values in MQTT during normal printing (e.g., `0x03000002` → short code `0300_0002`). The `print_error` parser treated any non-zero value as a real error, appending it to `hms_errors` and triggering notifications — even though the printer was printing fine. All known real HMS error codes have their low 16 bits >= `0x4000` (`0x4xxx` = fatal, `0x8xxx` = warning/pause, `0xCxxx` = prompt). Values below `0x4000` are status/phase indicators, not faults. Now skips values where the error portion is below `0x4000` in both the `print_error` and `hms` array parsers.
- **Spool Auto-Assign Fails With Greenlet Error** ([#612](https://github.com/maziggy/bambuddy/issues/612)) — RFID spool auto-assignment logged `WARNING greenlet_spawn has not been called; can't call await_only() here` and silently failed. The `Spool.assignments` relationship was never eagerly loaded: when `auto_assign_spool()` created a new `SpoolAssignment` and called `db.add()`, SQLAlchemy resolved the FK back-populates synchronously (outside the async greenlet), triggering a lazy load on the uninitialized `spool.assignments` collection. The previous fix only covered `spool.k_profiles`. Now also initializes `spool.assignments = []` on newly created spools in `create_spool_from_tray()`, and adds `selectinload(Spool.assignments)` to both queries in `get_spool_by_tag()` for existing spools. Added `exc_info=True` to the error handlers for full tracebacks in future logs.
- **SpoolBuddy Link Tag Missing tag_type** — Linking an NFC tag to a spool via the SpoolBuddy dashboard's "Link to Spool" action only set `tag_uid` but left `tag_type` and `data_origin` empty, because it called the generic `updateSpool` API instead of the dedicated `linkTagToSpool` endpoint. The printer card's `LinkSpoolModal` already used `linkTagToSpool` correctly. Now uses `linkTagToSpool` with `tag_type: 'generic'` and `data_origin: 'nfc_link'`, which also handles conflict checks and archived tag recycling.
- **SpoolBuddy AMS Page Missing Fill Levels for Non-BL Spools** — AMS slots with non-Bambu Lab spools assigned to inventory didn't show fill level bars on the SpoolBuddy AMS page, even though the main printer card displayed them correctly. The SpoolBuddy AMS page only used the MQTT `remain` field (which is -1/unknown for non-BL spools), while the printer card had a fallback chain: Spoolman → inventory → AMS remain. Now fetches inventory spool assignments and computes fill levels from `(label_weight - weight_used) / label_weight`, falling back to AMS remain when no inventory assignment exists.
- **SpoolBuddy AMS Page Ext-R Slot Falsely Shown as Active When Idle** — On dual-nozzle printers (H2D), the Ext-R slot was incorrectly highlighted as active when the printer was idle. The ext-R tray has `id=255`, and the idle sentinel `tray_now=255` matched it via `trayNow === extTrayId`. The main printer card avoided this by clearing `effectiveTrayNow` to `undefined` when `tray_now=255`. Now guards against `tray_now=255` before any ext slot active check.
- **Printer Card Loses Info When Print Is Paused** ([#562](https://github.com/maziggy/bambuddy/issues/562)) — When a print was paused (via G-code pause command or user action), the printer card showed the print as finished — the progress bar, print name, ETA, layer count, and cover image all disappeared, replaced by the idle "Ready to Print" placeholder. The display conditions only checked for `state === 'RUNNING'` but not `'PAUSE'`, even though other parts of the same page (Skip Objects button, Stop/Resume controls) already handled both states correctly. Now shows print progress info for both `RUNNING` and `PAUSE` states, and the status label correctly reads "Paused" instead of the hardcoded "Printing" fallback.
- **SpoolBuddy "Assign to AMS" Slot Shows Empty Fields in Slicer** — After assigning a spool to an AMS slot via SpoolBuddy's "Assign to AMS" button, the slicer's slot overview showed the correct filament, but opening the slot detail card showed all fields empty/unselected. Two bugs: (1) the `assign_spool` backend called the cloud API with the raw `slicer_filament` value including its version suffix (e.g., `PFUS9ac902733670a9_07`), which returned a 404; the silent fallback sent the `setting_id` as `tray_info_idx` instead of the real `filament_id` (e.g., `PFUS9ac902733670a9` instead of `P4d64437`), and the slicer couldn't resolve the preset; (2) no `SlotPresetMapping` was saved, so Bambuddy's own ConfigureAmsSlotModal couldn't identify the active preset when reopened. Now strips version suffixes before the cloud lookup, resolves the real `filament_id` via the cloud API (with local preset and generic ID fallbacks), includes the brand name in `tray_sub_brands`, and saves the slot preset mapping from the frontend after assignment.
- **Virtual Printer Bind Server Fails With TLS-Enabled Slicers** ([#559](https://github.com/maziggy/bambuddy/issues/559)) — BambuStudio uses TLS on port 3002 for certain printer models (e.g. A1 Mini / N1), but the bind server only spoke plain TCP on both ports 3000 and 3002. The slicer's TLS ClientHello was rejected as an "invalid frame", preventing discovery and connection entirely. Port 3002 now uses TLS (using the VP's existing certificate), while port 3000 remains plain TCP for backwards compatibility. The proxy-mode bind proxy was also updated to use TLS termination on port 3002.
- **Queue Returns 500 When Cancelled Print Exists** ([#558](https://github.com/maziggy/bambuddy/issues/558)) — When a print was cancelled mid-print, the MQTT completion handler stored status `"aborted"` on the queue item, but the response schema only accepts `"pending"`, `"printing"`, `"completed"`, `"failed"`, `"skipped"`, or `"cancelled"`. Listing all queue items hit a Pydantic validation error on the invalid status, returning a 500 error. Filtering by a specific status (e.g. "pending") excluded the bad row and worked fine. Now normalises `"aborted"` to `"cancelled"` before storing. A startup fixup also converts any existing `"aborted"` rows.
- **Tests Send Real Maintenance Notifications** — Tests that call `on_print_complete(status="completed")` created background `asyncio` tasks (maintenance check, smart plug, notifications) that outlived the test's mock context. When the event loop processed these orphaned tasks, `async_session` was no longer patched and they queried the real production database — finding real printers with maintenance due and real notification providers, then sending real notifications. Tests now cancel spawned background tasks before the mock context exits.
- **Virtual Printer Config Changes Ignored Until Toggle Off/On** — Changing a virtual printer's mode (e.g. proxy → archive), model, access code, bind IP, remote interface IP, or target printer via the UI updated the database but the running VP instance was never restarted. `sync_from_db()` skipped any VP whose ID was already in the running instances dict without checking if config had changed. Now compares critical fields between the running instance and DB record and restarts the VP when a difference is detected.
- **Sidebar Navigation Ignores User Permissions** — All sidebar navigation items (Archives, Queue, Stats, Profiles, Maintenance, Projects, Inventory, Files) were visible to every user regardless of their role's permissions. Only the Settings item was permission-gated. Now each nav item is hidden when the user lacks the corresponding read permission (e.g., `archives:read`, `queue:read`, `library:read`). The Printers item remains always visible as the home page. Also added the missing `inventory:read|create|update|delete` permissions to the frontend Permission type (they existed in the backend but were absent from the frontend type definition).
- **Camera Button Clickable Without Permission & ffmpeg Process Leak** ([#550](https://github.com/maziggy/bambuddy/issues/550)) — Two camera issues in multi-user environments (e.g., classrooms with multiple printers). First, the camera button on the printer card was clickable even when the user's role lacked `camera:view` permission. Now disabled with a permission tooltip, matching the existing pattern for `printers:control` on the chamber light button. Second, ffmpeg processes (~240MB each) were never cleaned up after closing a camera stream. The `stop_camera_stream` endpoint called `terminate()` but never `wait()`ed or `kill()`ed, and HTTP disconnect detection in the streaming response only checked between frames — if the generator was blocked reading from ffmpeg stdout, disconnect was never detected (due to TCP send buffer masking the closed connection). Three fixes: (1) the stop endpoint now uses `terminate()` → `wait(2s)` → `kill()` → `wait()`; (2) each stream gets a background disconnect monitor task that polls `request.is_disconnected()` every 2 seconds independently of the frame loop, directly killing the ffmpeg process on disconnect; (3) a periodic cleanup (every 60s) scans `/proc` for any ffmpeg process with a Bambu RTSP URL (`rtsps://bblp:`) that isn't in an active stream and `SIGKILL`s it — catching orphans that survive app restarts or generator abandonment.
- **Windows Install Fails With "Syntax of the Command Is Incorrect"** ([#544](https://github.com/maziggy/bambuddy/issues/544)) — The `start_bambuddy.bat` Python hash verification used a multi-line `for /f "usebackq"` with a backtick-delimited command split across lines. Windows CMD cannot parse line breaks inside backtick-delimited `for /f` commands, causing "The syntax of the command is incorrect" immediately after downloading Python. The entire block was also redundant — it downloaded a separate checksum file from python.org and re-verified the hash, but `verify_sha256` had already checked the archive against the pinned hash on the previous line. Removed the duplicate verification block. Also had a secondary bug: always downloaded the `amd64` checksum even on `arm64` systems.
- **Queue Badge Shows on Incompatible Printers** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — The purple queue counter badge in the printer card header showed on all printers of the same model when a job was scheduled for "any [model]", even if the printer didn't have the matching filament color loaded. The `PrinterQueueWidget` (which shows "Clear Plate & Start") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count.
- **SpoolBuddy Daemon Can't Find Hardware Drivers** — The daemon's `nfc_reader.py` and `scale_reader.py` import `read_tag` and `scale_diag` as bare modules, but these files live in `spoolbuddy/scripts/` which isn't on Python's module search path. The systemd service sets `WorkingDirectory` to `spoolbuddy/` and runs `python -m daemon.main`, so only the `spoolbuddy/` and `daemon/` directories are on `sys.path`. Added `scripts/` to `sys.path` at daemon startup, resolved relative to the module file so it works regardless of install path. Also moved the `read_tag` import inside `NFCReader.__init__`'s try/except block — it was previously outside, so a missing module crashed the entire daemon instead of gracefully skipping NFC polling. Demoted hardware-not-available log messages from ERROR to INFO since missing modules are expected when hardware isn't connected.
- **SpoolBuddy Scale Tare & Calibration Not Applied** — The SpoolBuddy scale tare and calibrate buttons on the Settings page queued commands but never executed them. Five bugs in the chain: (1) the daemon received the `tare` command via heartbeat but never called `scale.tare()` — a comment said "need cross-task communication" but the ScaleReader was already available in the shared dict; (2) no API endpoint existed for the daemon to report the new tare offset back to the backend database, so tare results were lost; (3) when calibration values changed in heartbeat responses, the daemon updated its config object but never called `scale.update_calibration()`, so the ScaleReader kept using its initial values forever; (4) the heartbeat response that delivered the tare command still contained pre-tare calibration values, which immediately overwrote the new tare offset back to zero; (5) the `set-factor` endpoint computed `calibration_factor` using the DB `tare_offset`, which could be stale or zero if the tare hadn't persisted yet — producing a wildly wrong factor (e.g., 5000g displayed with empty scale). Added a `POST /devices/{device_id}/calibration/set-tare` endpoint and `update_tare()` API client method. The heartbeat loop now executes `scale.tare()` when the tare command is received, persists the result via the new endpoint, propagates calibration changes to the ScaleReader instance, and skips calibration sync on the heartbeat cycle that delivers a tare command. The calibration flow now captures the raw ADC at tare time and sends it alongside the loaded-weight ADC in step 2, so the factor is computed from the actual tare reference rather than the DB value — making calibration self-contained and independent of the tare persistence round-trip. The calibration weight input uses a compact touch-friendly numpad since the RPi kiosk has no physical keyboard.
- **A1 Mini Shows "Unknown" Status After MQTT Payload Decode Failure** ([#549](https://github.com/maziggy/bambuddy/issues/549)) — Some printer firmware versions (observed on A1 Mini 01.07.02.00) occasionally send MQTT payloads containing non-UTF-8 bytes. The `_on_message` handler called `msg.payload.decode()` (strict UTF-8), and the resulting `UnicodeDecodeError` was not caught — only `json.JSONDecodeError` was handled. The entire message was silently dropped, causing printer status to show "unknown", temperatures to read 0°C, and AMS data to disappear. Now catches `UnicodeDecodeError` and falls back to `decode(errors="replace")`, which substitutes invalid bytes with U+FFFD while keeping the JSON structure intact. Logs a warning for diagnostics.
- **H2C Dual Nozzle Variant (O1C2) Not Recognized** ([#489](https://github.com/maziggy/bambuddy/issues/489)) — The H2C dual nozzle variant reports model code `O1C2` via MQTT, but only `O1C` was in the recognized model maps. This caused the camera to use the wrong protocol (chamber image on port 6000 instead of RTSP on port 322) — the printer immediately closed the connection, producing a reconnect loop. Also affected model display names, chamber temperature support detection, linear rail classification, and virtual printer model mapping. Added `O1C2` to all model ID maps across backend and frontend.
- **Support Package Leaks Full Subnet IPs and Misdetects Docker Network Mode** — Three support package fixes. First, the network section included full subnet addresses (e.g., `192.168.192.0/24`); now masks the first two octets (`x.x.192.0/24`). Second, `network_mode_hint` used `len(interfaces) > 2` which always reported "bridge" on single-NIC hosts even with `network_mode: host`, because `get_network_interfaces()` excludes Docker infrastructure interfaces. Now checks for the presence of Docker interfaces (`docker0`, `br-*`, `veth*`) via `socket.if_nameindex()` — these are only visible when the container shares the host network namespace. Third, `developer_mode` was still null for most users because the MQTT `fun` field was only parsed inside the `print` key; some firmware versions send it at the top level of the payload. Now also checks top-level `fun`. Also added a `virtual_printers` section with mode, model, enabled/running status, and pending file count for each configured virtual printer.
- **SpoolBuddy Scale Calibration Lost After Reboot** — The SpoolBuddy daemon generated its device ID from the MAC address of whichever network interface `Path.iterdir()` returned first, but filesystem iteration order is non-deterministic. On different boots, the daemon could pick `eth0` (MAC ending `3100`) or `wlan0` (MAC ending `3102`), producing a different `device_id` each time. Since calibration values (`tare_offset`, `calibration_factor`) are stored per device ID in the backend database, a new ID meant registering as a brand-new uncalibrated device. Fixed by sorting network interfaces alphabetically before selection, ensuring the same interface (and thus the same device ID) is always chosen.
- **SpoolBuddy NFC Reader Fails to Detect Tags** — The PN5180 NFC reader had two polling issues. First, each `activate_type_a()` call that returned `None` (no tag) corrupted the PN5180 transceive state — subsequent calls silently failed even when a tag was physically present, making it impossible to detect tags placed after startup (only tags already on the reader during init were detected). Fixed by performing a full hardware reset (RST pin toggle + RF re-init, ~240ms) before every idle poll, giving a ~1.8 Hz effective poll rate. Second, after a successful SELECT the card stayed in ACTIVE state and ignored subsequent WUPA/REQA, causing false "tag removed" events after ~1 second. Fixed with a light RF off/on cycle (13ms) before each poll when a tag is present, resetting the card to IDLE for re-selection. Also added error-based auto-recovery (full hardware reset after 10 consecutive poll exceptions), periodic status logging every 60 seconds, and accurate heartbeat reporting of NFC/scale health.

### Improved
- **SpoolBuddy AMS Page Single-Slot Card Layout** — AMS-HT and external spool cards on the SpoolBuddy AMS page now use a responsive grid (2 cards per AMS card width) instead of auto-sized flex items, so they align with the regular AMS card columns above. Regular AMS cards no longer stretch vertically to fill available space on printers with fewer AMS units.
- **SpoolBuddy Scale Value Stabilization** — The SpoolBuddy daemon now suppresses redundant scale weight reports: only sends updates when the weight changes by ≥2g. Previously every 1-second report interval sent a reading regardless of change, and stability state flips (stable ↔ unstable) also triggered reports — when ADC noise kept the spread hovering around the 2g stability threshold, the flag toggled every cycle, forcing a report with a slightly different weight each time. Removed stability flipping as a report trigger (the stable flag is still included in each report for consumers). Also increased the NAU7802 moving average window from 5 to 20 samples (500ms → 2s) to smooth ADC noise. The frontend also applies a 3g display threshold as defense-in-depth.
- **SpoolBuddy TopBar: Online Printer Selection** — The printer selector in the SpoolBuddy top bar now only shows online printers and auto-selects the first online printer. If the currently selected printer goes offline, it automatically switches to the next available online printer. Also replaced the placeholder icon with the SpoolBuddy logo. Renamed the connection status label from "Online" to "Backend" for clarity.
- **SpoolBuddy Assign to AMS Redesign** — The "Assign to AMS" sub-modal (opened from the spool card) is now a full-screen overlay that reuses the `AmsUnitCard` component from the AMS page. Regular AMS units display in a 2-column grid with the same spool visualization, fill bars, and material labels. AMS-HT and external slots (Ext / Ext-L / Ext-R on dual-nozzle printers) appear in a compact horizontal row below. Clicking any slot auto-configures the filament via a single `assignSpool` API call — the backend handles both the DB assignment and MQTT configuration. The printer selector was removed from the modal since the top bar already provides printer selection. Dual-nozzle printers show L/R nozzle badges on each AMS unit.
- **Filament ID Conversion Utility** — Extracted filament_id ↔ setting_id conversion logic into a shared utility (`backend/app/utils/filament_ids.py`). The `assign_spool` endpoint now normalizes `slicer_filament` (which can be stored in either filament_id format like "GFL05" or setting_id format like "GFSL05_07") into the correct `tray_info_idx` and `setting_id` for the MQTT command. Previously `setting_id` was always sent as empty string, which could cause BambuStudio to not resolve the filament preset for the AMS slot.
- **Updates Card Separates Firmware and Software Settings** — The Updates card on the Settings page mixed printer firmware and Bambuddy software update toggles with no visual grouping. Now splits the card into two labeled sections ("Printer Firmware" and "Bambuddy Software") separated by a divider, making it clear which toggles control what.
- **SpoolBuddy Test Coverage** — Added integration tests for all 12 SpoolBuddy API endpoints (21 backend tests covering device registration/re-registration, heartbeat status and pending commands, NFC tag scan/match/removal, scale reading broadcast, spool weight calculation, and scale calibration including tare, set-factor, and zero-delta error handling) and component tests for the three main SpoolBuddy frontend components (20 frontend tests covering WeightDisplay weight formatting and status indicators, SpoolInfoCard spool info rendering and action callbacks, UnknownTagCard tag display, and TagDetectedModal open/close/escape behavior with known and unknown spool views).
- **Cleanup Obsolete Settings** — The startup migration now deletes orphaned settings keys from the database that are no longer used by the application (e.g., `slicer_binary_path` from earlier slicer integration research).
- **Added HUF Currency** ([#579](https://github.com/maziggy/bambuddy/issues/579)) — Added Hungarian Forint (HUF, Ft) to the supported currencies list for filament cost tracking.
- **FTP Upload Progress & Speed** — Reduced FTP upload chunk size from 1MB to 64KB for smoother progress reporting — at typical printer FTP speeds (~50-100KB/s) the progress bar now updates roughly every second instead of appearing stuck for 20+ seconds between jumps. Removed the post-upload `voidresp()` wait for all printer models (previously only skipped for A1); H2D printers delay the FTP 226 acknowledgment by 30+ seconds after data transfer completes, causing a long hang at 100%. The data is already on the SD card once the transfer finishes. Also added transfer speed logging (KB/s) and PASV+TLS handshake timing to help diagnose slow connections.
- **Wider Print & Schedule Modals** — Increased the Print and Schedule Print modal width from 512px to 672px to better accommodate long filament profile names (e.g., "PLA Support for PETG PETG Basic @Bambu Lab H2D 0.4 nozzle").

## [0.2.1.1] - 2026-02-28


### Fixed
- **H2C Dual Nozzle Variant (O1C2) Not Recognized** ([#489](https://github.com/maziggy/bambuddy/issues/489)) — The H2C dual nozzle variant reports model code `O1C2` via MQTT, but only `O1C` was in the recognized model maps. This caused the camera to use the wrong protocol (chamber image on port 6000 instead of RTSP on port 322) — the printer immediately closed the connection, producing a reconnect loop. Also affected model display names, chamber temperature support detection, linear rail classification, and virtual printer model mapping. Added `O1C2` to all model ID maps across backend and frontend.
- **Sidebar Navigation Ignores User Permissions** — All sidebar navigation items (Archives, Queue, Stats, Profiles, Maintenance, Projects, Inventory, Files) were visible to every user regardless of their role's permissions. Only the Settings item was permission-gated. Now each nav item is hidden when the user lacks the corresponding read permission (e.g., `archives:read`, `queue:read`, `library:read`). The Printers item remains always visible as the home page. Also added the missing `inventory:read|create|update|delete` permissions to the frontend Permission type (they existed in the backend but were absent from the frontend type definition).
- **Camera Button Clickable Without Permission & ffmpeg Process Leak** ([#550](https://github.com/maziggy/bambuddy/issues/550)) — Two camera issues in multi-user environments (e.g., classrooms with multiple printers). First, the camera button on the printer card was clickable even when the user's role lacked `camera:view` permission. Now disabled with a permission tooltip, matching the existing pattern for `printers:control` on the chamber light button. Second, ffmpeg processes (~240MB each) were never cleaned up after closing a camera stream. The `stop_camera_stream` endpoint called `terminate()` but never `wait()`ed or `kill()`ed, and HTTP disconnect detection in the streaming response only checked between frames — if the generator was blocked reading from ffmpeg stdout, disconnect was never detected (due to TCP send buffer masking the closed connection). Three fixes: (1) the stop endpoint now uses `terminate()` → `wait(2s)` → `kill()` → `wait()`; (2) each stream gets a background disconnect monitor task that polls `request.is_disconnected()` every 2 seconds independently of the frame loop, directly killing the ffmpeg process on disconnect; (3) a periodic cleanup (every 60s) scans `/proc` for any ffmpeg process with a Bambu RTSP URL (`rtsps://bblp:`) that isn't in an active stream and `SIGKILL`s it — catching orphans that survive app restarts or generator abandonment.
- **Windows Install Fails With "Syntax of the Command Is Incorrect"** ([#544](https://github.com/maziggy/bambuddy/issues/544)) — The `start_bambuddy.bat` launcher had Unix (LF) line endings instead of Windows (CRLF). When a user's git config has `core.autocrlf=false` or `input`, the file is checked out with LF endings and `cmd.exe` cannot parse it. Added a `.gitattributes` file that forces CRLF for all `.bat` files regardless of git config.
- **Queue Badge Shows on Incompatible Printers** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — The purple queue counter badge in the printer card header showed on all printers of the same model when a job was scheduled for "any [model]", even if the printer didn't have the matching filament color loaded. The `PrinterQueueWidget` (which shows "Clear Plate & Start") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count.
- **A1 Mini Shows "Unknown" Status After MQTT Payload Decode Failure** ([#549](https://github.com/maziggy/bambuddy/issues/549)) — Some printer firmware versions (observed on A1 Mini 01.07.02.00) occasionally send MQTT payloads containing non-UTF-8 bytes. The `_on_message` handler called `msg.payload.decode()` (strict UTF-8), and the resulting `UnicodeDecodeError` was not caught — only `json.JSONDecodeError` was handled. The entire message was silently dropped, causing printer status to show "unknown", temperatures to read 0°C, and AMS data to disappear. Now catches `UnicodeDecodeError` and falls back to `decode(errors="replace")`, which substitutes invalid bytes with U+FFFD while keeping the JSON structure intact. Logs a warning for diagnostics.

## [0.2.1] - 2026-02-27

### Fixed
- **Timezone-Aware Datetime Comparisons Crash With SQLite** — The 0.2.1 timezone fix (`datetime.now(timezone.utc)`) produced aware datetimes, but SQLAlchemy's SQLite `DateTime` columns return naive datetimes on read. Any Python-side comparison between the two raised `TypeError: can't subtract offset-naive and offset-aware datetimes`, crashing the maintenance overview endpoint and potentially 7 other code paths (API key expiration, smart plug auto-off, power alert cooldown, runtime tracking, print scheduling, and timelapse matching). Added `tzinfo is None` guards before all database datetime comparisons.
- **FTP Proxy Cannot Bind to Port 990 in Docker** — The `cap_add: NET_BIND_SERVICE` in docker-compose.yml didn't reliably propagate to the Python process when running as a non-root user (`user:` directive), depending on the container runtime's ambient capability support. Now sets the file capability directly on the Python binary in the Dockerfile via `setcap`, which the kernel honors regardless of runtime configuration.
- **AMS History Chart Shows Wrong Time Range** ([#535](https://github.com/maziggy/bambuddy/issues/535)) — The AMS temperature/humidity chart X axis was fitted to only the data points present (`dataMin`/`dataMax`), not the selected time window. When the printer was offline for part of the period, shorter views (e.g., 6h) appeared compressed to only the portion with data (e.g., 1.5h). Now pins the X axis domain to the full requested time range (e.g., now−6h to now), pads the data edges so the line extends across the full window, and connects through null values so the chart always shows a continuous line.
- **"Clear Plate & Start Next" Ignores Filament Override Color** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — When a print was queued to "any printer" with a filament color override (e.g., white PETG), the "Clear Plate & Start Next" button appeared on all printers of the matching model that had the correct filament *type*, regardless of *color*. A printer with blue PETG would show the button for a white PETG job. The backend scheduler already correctly rejected color mismatches, but the frontend `PrinterQueueWidget` only checked `required_filament_types` (type only) and ignored `filament_overrides` (type + color). Now passes loaded filament type+color pairs from AMS/vt_tray status to the widget and filters queue items against override colors, mirroring the backend's `_count_override_color_matches()` logic.
- **Queue Empty After Container Restart Due to Uncheckpointed WAL** ([#523](https://github.com/maziggy/bambuddy/issues/523)) — The print queue appeared empty after a Docker container restart until a filter was applied. SQLite WAL mode keeps uncommitted data in a separate `-wal` file, but the shutdown handler never checkpointed the WAL back into the main database or disposed of engine connections. If the container was stopped or crashed, the WAL could contain partial schema migrations or uncommitted data, causing inconsistent query results on restart. Deleting the `-wal` and `-shm` files was the only workaround. Now runs `PRAGMA wal_checkpoint(TRUNCATE)` and disposes the engine on shutdown, ensuring all data is flushed to the main database file before exit.
- **Virtual Printer Queue Sends Wrong Plate ID and Ignores AMS Mapping** ([#529](https://github.com/maziggy/bambuddy/issues/529)) — Files sent to a virtual printer in queue mode had two issues. First, `plate_id` was always `1`, generating the wrong MQTT gcode path for multi-plate 3MF files (HMS error 0500_4003). Now extracts the plate index from the 3MF's `slice_info.config`. Second, `ams_mapping` was never computed for printer-specific queue items (VP assigned to a particular printer), so the printer always used the first AMS slot regardless of which filament the 3MF required. The scheduler now computes AMS mapping for all queue items that lack one, not just model-based assignments.
- **Unnecessary Target Model Selector on "Any" Tab** ([#528](https://github.com/maziggy/bambuddy/issues/528)) — When scheduling a print to "Any {model}", a redundant "Target Model" dropdown appeared even though the G-code is already sliced for a specific printer model. Changing the target model would lead to print failures. The dropdown is now hidden when the sliced model is known (the tab label already shows "Any {model}"). It still appears as a fallback for legacy files without model metadata.
- **"Clear Plate & Start Next" Button Shown on Printers Without Correct Filament** ([#527](https://github.com/maziggy/bambuddy/issues/527)) — When a print job was queued for "any printer" of a model (e.g., "any H2S"), the "Clear Plate & Start Next" button appeared on ALL printers of that model, including those without the required filament loaded. Clicking it on a printer without the right filament would start a print that fails. The `PrinterQueueWidget` now filters queue items by filament compatibility — it checks the printer's loaded filament types (from AMS and external spools) against the queue item's `required_filament_types` and only shows items the printer can actually print. If no compatible items exist, the widget is hidden.
- **Manual Spool Weight Overwritten by AMS Auto-Sync** ([#525](https://github.com/maziggy/bambuddy/issues/525)) — When a user manually entered a spool weight (via UI or API), the value was overwritten by the automatic AMS remain% sync that runs on every MQTT update. The AMS remain% is integer-only (~10g resolution for 1kg spool) and can't match precise manual entries. Added a `weight_locked` flag that is automatically set when `weight_used` is explicitly updated via the API. Locked spools are skipped by both the automatic AMS remain% sync and the manual force-sync endpoint. The usage tracker (3MF/gcode delta tracking) is unaffected. Users can re-enable AMS sync by setting `weight_locked: false`.
- **Inconsistent Print Cost on Reprints** ([#505](https://github.com/maziggy/bambuddy/issues/505)) — Reprinting the same model produced different costs each time (e.g., £0.77, £1.54, £2.03 for the same print). Three independent code paths wrote to `archive.cost` with conflicting strategies: the usage tracker summed ALL historical `SpoolUsageHistory` rows for the archive (including rows from previous reprints), and a separate `add_reprint_cost` method added yet another full print's cost on top. Removed the redundant `add_reprint_cost` path entirely and changed the usage tracker to compute cost only from the current print session's results instead of querying all historical rows. `archive.cost` now always reflects the cost of a single print.
- **Timestamps Off by Timezone Offset in Non-UTC Docker Containers** ([#504](https://github.com/maziggy/bambuddy/issues/504)) — All backend timestamps used `datetime.now()` (server local time) or the deprecated `datetime.utcnow()`. The frontend's `parseUTCDate()` assumes timestamps without timezone indicators are UTC and appends `'Z'`, so when the container's timezone wasn't UTC, every stored timestamp was off by the timezone offset. Replaced all database and comparison timestamps with `datetime.now(timezone.utc)` across 16 backend files (~80 call sites). On the frontend, replaced 13 `new Date(backendTimestamp)` calls with `parseUTCDate()` across 8 files to correctly interpret UTC timestamps. Cosmetic timestamps (filenames, user-facing local time formatting) are intentionally left as local time.
- **"Power Off Printer" Option Not Gated by Control Permission** ([#500](https://github.com/maziggy/bambuddy/issues/500)) — The "Power off printer when done" checkbox in the print modal and the auto power off toggle in the bulk edit modal were accessible to all users regardless of permissions. Users without the `printers:control` permission can now no longer enable auto power off — the checkbox and tri-state toggle are disabled and visually dimmed.
- **Created Admin Users Can't See Settings Button** ([#503](https://github.com/maziggy/bambuddy/issues/503)) — The sidebar hid the Settings link based on a hardcoded `role === 'user'` check instead of the actual `settings:read` permission, so newly created admin users who had the permission still couldn't see the button. Also, after login the auth state was set directly from the login response instead of re-fetching the full auth status, which could miss permission data. Now uses `hasPermission('settings:read')` for the sidebar check and calls `checkAuthStatus()` after login to load the complete user state including permissions.
- **"Open in Slicer" Fails for Filenames Containing Special Characters** — Filenames with `/`, `\`, `?`, or `#` (e.g., `Abzweigdose/Verteilerdose 70mm`) caused the slicer protocol handler to fail. The filename is placed in the download URL path and `encodeURIComponent`-encoded, but BambuStudio and OrcaSlicer call `url_decode()` on the entire protocol handler URL before downloading. This decoded `%2F` back to `/`, creating extra path segments that resulted in a 404. The URL filename is purely cosmetic (the backend resolves files by archive ID, not filename), so now sanitizes `/`, `\`, `?`, and `#` to `_` in slicer download URLs.
- **"Queue to Any Printer" Ignores Filament Color Override** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — When scheduling a print to "any printer" with a filament color override, the scheduler picked a printer with the correct filament type but wrong color. `_find_idle_printer_for_model()` validated only filament type (via `_get_missing_filament_types()`), while color matching (`_count_override_color_matches()`) was used only for ranking candidates, not filtering them. A printer with 0 color matches was still selected if it had the right types. Now requires at least 1 color match when filament overrides specify colors — printers with 0 matches are skipped and added to the "waiting for filament" reason instead of being treated as valid candidates.
- **Virtual Printer Queue Mode Doesn't Assign Printer** ([#518](https://github.com/maziggy/bambuddy/issues/518)) — Files sent to a virtual printer in "print queue" mode were added to the queue with no printer assigned, requiring manual assignment. The `_add_to_print_queue()` method always created queue items with `printer_id=None` and no `target_model`. Now assigns the virtual printer's `target_printer_id` if configured, or falls back to the VP's model (e.g., P1S, X1C) as `target_model` for "Any Printer" scheduling.
- **Settings Text Fields Reset While Typing** — Text input fields on the Settings page (MQTT broker hostname, HA URL, tokens, etc.) reset mid-typing because the auto-save `onSuccess` handler overwrote `localSettings` with the server response, discarding characters typed during the save request. Removed the stale state overwrite so in-progress user input is preserved.

### Improved
- **Queue API Returns More Print Metadata** ([#524](https://github.com/maziggy/bambuddy/issues/524)) — The `GET /api/v1/queue` and `GET /api/v1/queue/{id}` endpoints now include `filament_type`, `filament_color`, `layer_height`, `nozzle_diameter`, and `sliced_for_model` from the archive or library file. Previously these fields were only available via the archive endpoints, requiring an extra API call.
- **Spool Form Profile Dropdown Truncates Long Names** ([#534](https://github.com/maziggy/bambuddy/issues/534)) — Long filament profile names (e.g., "Polymaker Panchroma Matte PLA 0.4 nozzle P1S") were truncated in the spool creation form's preset dropdown because filament ID codes displayed alongside each name consumed horizontal space. Removed the inline filament codes from dropdown items (the selected code is still shown below the input after selection) and widened the modal from `max-w-lg` to `max-w-xl` to give profile names more room.

## [0.2.1b3] - 2026-02-23

### Fixed
- **Print Bed Cooled Notification Never Triggers** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — The bed cooldown monitor (which polls bed temperature after a print and sends a notification when it drops below the configured threshold) was defined at the end of the `on_print_complete` callback, after an early `return` that exits when no archive is found for the print. Prints started from BambuStudio or the printer's touchscreen typically have no archive in Bambuddy, so the function returned before the bed cooldown task was ever created. Moved the bed cooldown monitor to before the archive lookup early-return so it fires for all completed prints regardless of archive state. Also hardened the temperature dict check from truthiness (`if status.temperatures:`) to type check (`isinstance(status.temperatures, dict)`) to avoid false negatives on empty dicts.
- **IP Addresses Not Redacted From Support Bundle Logs** — The `_sanitize_log_content()` function redacted emails, serials, and credentials but left raw IPv4 addresses in log output. Now adds known printer IPs to the sensitive string list for exact matching, and applies an IPv4 regex that replaces addresses with `[IP]` while preserving firmware version strings (which use leading-zero octets like `01.09.01.00`). Updated the system info page privacy disclaimer to list IP addresses as redacted.
- **"Unknown stage (74)" on H2D During Print Preparation** — The H2D firmware reports `stg_cur=74` during print preparation, but this stage was not in the stage name lookup table (which went up to 66, sourced from BambuStudio). Now maps stage 74 to "Preparing". Also added stage 77 ("Preparing AMS") which was present in BambuStudio but missing from the lookup.
- **Wrong Documentation Link for "Lubricate Carbon Rods" on P2S** ([#490](https://github.com/maziggy/bambuddy/issues/490)) — The "Lubricate Carbon Rods" maintenance task linked to the belt tension wiki page instead of the XYZ axis lubrication page for P2S printers.
- **External Spool Mapping Inverted on H2C** ([#492](https://github.com/maziggy/bambuddy/issues/492)) — On H2C dual-nozzle printers, printing from the right nozzle's external spool (Ext-R) incorrectly highlighted the left external spool (Ext-L) as active. The H2C firmware reports `tray_now=254` generically for both external spools, so the frontend's direct ID comparison (`effectiveTrayNow === extTrayId`) always matched Ext-L (id=254). Now uses `active_extruder` on dual-nozzle printers to determine which external spool is active: extruder 1 (left) → Ext-L, extruder 0 (right) → Ext-R.
- **External Spool Assignments Lost on Restart** ([#493](https://github.com/maziggy/bambuddy/issues/493)) — Filament spool assignments on external spool holders (Ext-L / Ext-R) were silently deleted every time AMS data changed, including on container restart. The `on_ams_change` stale-assignment cleanup searched only AMS unit data for matching trays, but external spools live in `vt_tray` (a separate MQTT field). Since `_find_tray_in_ams_data` never found them, external assignments were always marked as stale and removed. Now looks up external spool assignments (`ams_id=255`) in the printer's `vt_tray` data instead, and keeps the assignment if `vt_tray` data hasn't arrived yet.
- **Developer Mode Detection Always Reports Null** — The MQTT `fun` field is an integer in the JSON payload, but the parser used `int(value, 16)` which requires a string argument. This raised `TypeError` on every message, silently caught by the exception handler, so `developer_mode` was never set. Now handles both integer and hex string formats.
- **Filament Fill Level Wrong in Hover Card / Missing for External Spools** ([#496](https://github.com/maziggy/bambuddy/issues/496)) — Three related fill level display bugs on the printer card. First, external spool slots (vt_tray) were missing the AMS `remain` fallback entirely — `extEffectiveFill` only checked Spoolman and inventory, falling through to `null` even when the printer reported a valid fill percentage. Now includes the same AMS remain fallback as regular and AMS-HT slots. Second, when fill level was unknown (`null`), the AMS slot visual showed a full-width gray bar (appearing "full") while the hover card showed "—" (appearing "empty") — confusing users into thinking the printer card and hover card disagreed. Removed the misleading gray fallback bar from all three slot types; the empty fill bar track now consistently indicates "unknown" in both views. Third, the fill level priority chain always preferred AMS `remain` over Spoolman and inventory data, even when those sources were more accurate (e.g., spools migrated from Spoolman to internal inventory, or spools with accurate usage tracking). Reversed the priority to Spoolman → Inventory → AMS remain, and fixed `fillSource` to correctly reflect the actual data source used (was always reporting `'ams'` even when Spoolman or inventory provided the value via the fallback chain when `remain` was -1).
- **File Manager Rename Doesn't Update Displayed Name** ([#460](https://github.com/maziggy/bambuddy/issues/460)) — Renaming a file in the File Manager updated the `filename` field but not `file_metadata.print_name`, which the UI uses as the primary display name. Since `print_name` is extracted from inside the 3MF at upload time, it always took precedence over the renamed `filename`. The rename endpoint now also updates `print_name` in the file metadata when present.
- **Finish Photo Not Captured When Archive Has No Source 3MF** ([#484](https://github.com/maziggy/bambuddy/issues/484)) — When a print completed but the 3MF source file wasn't downloaded from the printer (e.g. FTP download failure), the archive's `file_path` was null. The finish photo capture silently skipped because it derived the save directory from `file_path`. Now falls back to `archive/{id}/` so the photo is captured regardless.

### New Features
- **Filament Override for Model-Based Queue** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — When scheduling a print to "any printer" (model-based assignment), you can now override the 3MF's original filament choices. A new section in the print modal shows the filaments required by the sliced file and lets you swap each slot to any compatible filament loaded across printers of the selected model. The scheduler matches against the overridden type and color instead of the original 3MF values, preferring printers with exact color matches. On dual-nozzle printers (H2D), the override dropdown only shows filaments on the correct extruder for each slot. New `GET /printers/available-filaments` endpoint aggregates loaded filaments across all active printers of a given model. Backend stores overrides as a JSON column on the queue item and applies them at scheduling time by merging into filament requirements before AMS mapping. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).

## [0.2.1b2] - 2026-02-21

### Fixed
- **Wrong AMS Unit Displayed With Dual AMS on P2S** ([#420](https://github.com/maziggy/bambuddy/issues/420)) — On P2S printers with two AMS units, the UI highlighted the wrong AMS when printing from the second unit (e.g., printing from AMS-B slot 2 but AMS-A slot 2 was shown as active). The P2S firmware sends local slot IDs (0-3) in `tray_now`, not global tray IDs — contrary to the previous assumption that all single-nozzle printers report global IDs. Filament usage tracking was unaffected because it uses the MQTT `mapping` field (snow-encoded with correct AMS hardware IDs). The display now cross-references `tray_now` with the MQTT mapping field to resolve the correct AMS unit when multiple AMS units are detected via `ams_exist_bits`. Falls back to the raw value when no mapping is available (e.g., manual filament load outside of a print) or when the mapping is ambiguous.
- **PCTG Filament Misidentified as PC** ([#478](https://github.com/maziggy/bambuddy/issues/478)) — Selecting "Generic PCTG" as a filament profile defaulted to PC material. The spool form's material parser listed PC before PCTG and used substring matching (`indexOf`), so "PCTG" matched "PC" first. The AMS slot configuration and local profiles views were also missing PCTG from their known material types. Additionally, the temperature range logic used `includes('PC')` which matched PCTG and assigned PC temperatures (260-300°C) instead of PETG-range temperatures (220-260°C). Fixed by reordering PCTG before PC in the spool form parser, adding PCTG to all material type arrays, and adding an exact-match temperature case for PCTG.
- **Phantom Prints From Lingering SD Card Files** ([#477](https://github.com/maziggy/bambuddy/issues/477)) — Prints could restart without user input hours after completing, because uploaded gcode files survived on the printer's SD card and were auto-started on firmware restart. Three bugs allowed files to linger. First, the post-print SD card cleanup retry loop always broke after the first attempt regardless of success, because `delete_file_async` catches errors internally and returns `False` instead of raising — the `except` retry branch never executed. Fixed by only breaking on successful delete and retrying with a 2-second delay on failure. Second, when `start_print()` failed after uploading a file (in both the background dispatcher and print scheduler), the uploaded file was never cleaned up since `on_print_complete` never fires for a print that never started. Now deletes the uploaded file on a best-effort basis when `start_print()` returns `False`. Third, cleanup failure logging was at `DEBUG` level, making failures invisible in normal operation — escalated to `WARNING`.
- **Non-Actionable HMS Errors Triggering Notifications** ([#470](https://github.com/maziggy/bambuddy/issues/470)) — Infrastructure and auth-related HMS error codes (like `0500_0007` "MQTT command verification failed") were triggering printer error notifications even though they don't indicate actual print problems. For example, a device with incorrect bind settings sending unauthorized MQTT commands caused repeated false-alarm nozzle/extruder error notifications with camera snapshots of perfectly fine prints. Now suppresses notifications for known non-actionable error codes: `0500_0007` (MQTT auth failure), `0500_4001` (Bambu Cloud connection failure), and `0500_400E` (print cancelled by user).
- **Support Bundle Leaking Personal Data** ([#473](https://github.com/maziggy/bambuddy/issues/473)) — The support bundle's log sanitizer only used regex patterns, which can't detect arbitrary user-chosen strings like printer names and usernames. Now queries the database for known sensitive values (printer names, serial numbers, auth usernames, Bambu Cloud email) and does exact-string replacement before the regex pass. Serial number regex no longer leaks the first 3 characters (was using a capture group for partial redaction). Tasmota smart plug credentials embedded in URLs (`http://user:pass@host`) were logged verbatim by httpx; now uses httpx's `auth` parameter for HTTP Basic auth so credentials never appear in the URL. Added `username` and `path` to the settings key filter to redact `smtp_username` and `slicer_binary_path` from the support info JSON. A URL credentials regex provides defense-in-depth for any remaining `user:pass@` patterns in logs. IP addresses are no longer redacted from the bundle as they are needed for connectivity debugging. Updated the frontend privacy disclaimer and wiki documentation to reflect the new behavior.
- **Spool Usage Lost When Spool Runs Empty Mid-Print** ([#459](https://github.com/maziggy/bambuddy/issues/459)) — When a spool ran empty during a print and the AMS auto-switched to a backup spool, two problems caused incorrect tracking. First, the `on_ams_change` handler eagerly deleted the empty spool's `SpoolAssignment` record (fingerprint mismatch), so `on_print_complete` found nothing and silently dropped usage — fixed by snapshotting all spool assignments at print start into the `PrintSession`. Second, even with the snapshot fix, the entire print's filament weight was attributed to the original spool (100%/0% split) because `_track_from_3mf()` only knew about the tray loaded at print start. Now tracks tray changes during the print via `tray_change_log` on `PrinterState`, recording each tray switch with its layer number. At print completion, the usage tracker splits the 3MF weight across trays using per-layer gcode data for precise segment boundaries, with a linear layer-ratio fallback when gcode data isn't available. The last segment always receives the remainder to prevent rounding drift.
- **K-Profile Response Race Condition Crash** ([#462](https://github.com/maziggy/bambuddy/issues/462)) — An unsolicited or late K-profile MQTT response could crash the MQTT handler with `AttributeError: 'NoneType' object has no attribute 'set'`. The MQTT callback thread checked `self._pending_kprofile_response` (not None) at line 2698, but between that check and the `.set()` call, the asyncio thread's `finally` block in `get_kprofiles()` could clear the attribute to `None` after a timeout — a classic TOCTOU race. Fixed by capturing the event reference in a local variable before the check.
- **Queue Stuck on "Busy" for "Any Model" Jobs** ([#435](https://github.com/maziggy/bambuddy/issues/435)) — When a print was queued with "Any [Model]" (e.g., "Any P1S"), it was created with `printer_id=NULL` and `target_model="P1S"`. After the assigned printer finished, the queue widget queried only for items matching `printer_id=X`, missing the next pending model-based item (`printer_id IS NULL`). With no next item found, the "Clear Plate & Start Next" button never appeared, leaving the scheduler stuck reporting "Busy". The queue API now accepts an optional `target_model` parameter; when combined with `printer_id`, it uses OR logic to also return unassigned items whose `target_model` matches the printer's model. The frontend passes the printer's model through to this query. Additionally, the backend now resolves the printer's model server-side from the database when the frontend doesn't provide `target_model` (e.g., when the printer was added without selecting a model), ensuring the OR logic works regardless of whether the client knows the printer's model.
- **Queue "Any Model" Jobs Stuck in "Waiting" After Plate Clear** ([#435](https://github.com/maziggy/bambuddy/issues/435)) — After the queue visibility fix above, "Any Model" jobs were correctly assigned to an idle printer but immediately crashed with `'>=' not supported between instances of 'str' and 'int'` when computing AMS filament mapping. MQTT raw data returns AMS unit and tray IDs as strings, but `_build_loaded_filaments()` compared them to integers without casting. The crash prevented the assignment from committing, so the scheduler retried every 30 seconds in an infinite loop. Cast `ams_id` and `tray_id` to `int()` to match the pattern already used for external spool IDs.
- **SD Card Cleanup After Print Never Runs** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — The post-print SD card cleanup (which deletes uploaded gcode from the printer root to prevent phantom prints on power cycle) used `printer_manager.get_printer()`, which returns a `PrinterInfo` with only `name` and `serial_number`. Accessing `.ip_address`, `.access_code`, and `.model` raised `AttributeError`, silently caught by the outer exception handler. Replaced with a DB query for the `Printer` model, matching the pattern used everywhere else in `on_print_complete()`.
- **Finish Photo Not Shown on Archives for BambuStudio Prints** ([#474](https://github.com/maziggy/bambuddy/issues/474)) — When a print was started from BambuStudio (not Bambuddy), the auto-archive had an empty `file_path`. The finish photo was saved correctly to `data/photos/`, but the photo serving endpoint resolved the path as `(base_dir / "").parent / "photos/"` which evaluates to `base_dir.parent/photos/` — one directory level too high. The photo existed on disk but the API returned 404. Fixed the path resolution in `get_photo`, `upload_photo`, and `delete_photo` to use `base_dir / Path(file_path).parent` (same pattern as the save code), which correctly resolves to `base_dir/photos/` when `file_path` is empty.
- **Archive Endpoints Crash With "Is a directory" for BambuStudio Prints** ([#475](https://github.com/maziggy/bambuddy/issues/475)) — When a print was started from BambuStudio (not Bambuddy), the 3MF file is transient on the printer and FTP download fails, creating a fallback archive with `file_path=""`. The archive endpoints used `Path.exists()` to check if the 3MF file was available, but `settings.base_dir / ""` resolves to the base directory itself — which `exists()` reports as True. Subsequent `ZipFile()` calls then failed with `[Errno 21] Is a directory`. Replaced all `.exists()` checks on archive file paths with `.is_file()` across 15 locations in the archive routes and 1 in the main module. Also added a `file_path` truthiness guard for finish photo capture to prevent saving photos under the base directory when the archive has no file path.
- **AMS Slot Auto-Configuration Falls Back to Generic Instead of Spool's Slicer Preset** ([#479](https://github.com/maziggy/bambuddy/issues/479)) — When assigning a spool with a custom slicer preset (e.g., PFUS* cloud-synced profiles from BambuStudio) to an AMS slot, the slot was always configured with a generic Bambu filament ID (e.g., "Generic ABS" / GFB99) instead of the spool's actual preset. Two bugs caused this. First, all PFUS* IDs were blanket-rejected as "user-local IDs unknown to other slicers" and replaced with generic IDs — but PFUS presets are cloud-synced custom profiles that the printer understands. Second, the slot-reuse logic preserved generic fallback IDs (GFB99, GFL99, etc.) as if they were specific presets: once a slot was set to generic, every subsequent same-material assignment reused it, making generic IDs "sticky". Fixed priority order: (1) spool's own `slicer_filament` if set (including PFUS*/P* custom presets), (2) reuse slot's existing preset only if it's a specific non-generic ID for the same material, (3) generic Bambu filament ID as last resort. Both `assign_spool` and `configure_ams_slot` code paths are fixed.
- **ntfy Notifications Fail With "Illegal header value"** ([#466](https://github.com/maziggy/bambuddy/issues/466)) — When sending ntfy notifications with image attachments (progress, error events), the message body was placed in an HTTP `Message` header. Multi-line messages (e.g., printer name + remaining time) contain newline characters, which are illegal in HTTP headers. Test notifications worked because they are single-line with no image. Now escapes newlines to literal `\n` in the header, which ntfy interprets and renders as actual line breaks. Additionally, ntfy servers with attachments disabled rejected thumbnail uploads with "attachments not allowed" (HTTP 400 / code 40014), causing the entire notification to fail. Now automatically retries without the image when the server doesn't support attachments.
- **Inventory Date Format Ignores Settings** ([#463](https://github.com/maziggy/bambuddy/issues/463)) — The inventory page used a local `formatDate()` that hardcoded the `en-GB` locale, always displaying dates in a fixed format regardless of the date format setting. Now fetches the `date_format` setting and uses the shared `formatDateInput()` utility which formats as MM/DD/YYYY, DD/MM/YYYY, YYYY-MM-DD, or browser locale based on the user's choice.
- **Inventory Location Shows Garbled Characters for AMS-HT Slots** ([#463](https://github.com/maziggy/bambuddy/issues/463)) — The inventory location column computed slot letters via `String.fromCharCode(65 + ams_id)`, which produced accented characters (e.g., `Á`) for AMS-HT units (ams_id ≥ 128). Now uses the shared `formatSlotLabel()` utility which correctly handles AMS-HT and external spool slots.

### New Features
- **Bulk Spool Addition & Stock Spools** ([#480](https://github.com/maziggy/bambuddy/issues/480)) — Inventory enhancements for managing large filament collections. **Quick Add mode**: a toggle on the spool form that shows only material (required), brand, subtype (both optional), color, label weight, and quantity — ideal for inventorying filament without a specific slicer profile ("stock" spools). The quantity field (1–100) only appears in Quick Add mode and creates multiple identical spools in one transaction via `POST /inventory/spools/bulk`. Stock spools are computed (no database migration) — any spool without a `slicer_filament` is displayed with an amber "Stock" badge. A new filter (All / Stock / Configured) on the inventory page lets you filter by stock status. **Group similar spools**: a "Group" toggle in the inventory toolbar visually collapses identical unused/unassigned spools into a single expandable row or card with a count badge (e.g., "5 identical spools"). Grouping key uses material, subtype, brand, color, and label weight. Used or AMS-assigned spools always appear individually. Group state persists to localStorage. The Stock column is available but hidden by default in column settings. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
- **Filament Cost Tracking** ([#454](https://github.com/maziggy/bambuddy/pull/454), [#452](https://github.com/maziggy/bambuddy/issues/452)) — Track per-spool filament costs and see cost breakdowns for every print. Each spool can have a `cost_per_kg` value; when a print completes, the usage tracker calculates the cost from actual filament consumption and stores it in the usage history. Archive costs are automatically aggregated from spool usage records. A global `default_filament_cost` setting (Settings → Filament) provides a fallback when spools don't have individual costs set. The print modal shows a real-time cost preview based on loaded filaments. Archive cards display the total cost. The inventory table includes a sortable cost/kg column. The recalculate-costs endpoint can retroactively update all archive costs when filament prices change. Contributed by @Keybored02.
- **Background Print Dispatch** ([#408](https://github.com/maziggy/bambuddy/pull/408), [#112](https://github.com/maziggy/bambuddy/issues/112)) — Printing from archives and the file manager now runs in the background via an async dispatch service. FTP uploads and print-start commands are decoupled from API request latency, so the UI responds immediately. Real-time progress is streamed to all clients via WebSocket, rendered as a persistent toast with per-job upload progress bars, status badges (dispatched/processing/completed/failed/cancelled), and a cancel button. The dispatcher supports concurrent uploads to different printers with per-printer queuing to prevent conflicts. Cancellation is cooperative — uploads abort at the next chunk boundary and clean up partial files on the printer. Batch progress tracking shows overall completion across multi-printer dispatches. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
- **Include Beta Updates Setting** — New toggle in Settings → Updates to opt in to beta/prerelease update notifications. Default: off (stable only). The update checker now fetches `/releases` instead of `/releases/latest` and filters by `parse_version()` prerelease detection (not GitHub's `prerelease` flag, which may not be set correctly). Users on the Docker `latest` tag will no longer see notifications for beta releases they can't install.
- **Developer LAN Mode Detection & Warning Banner** — Automatically detects whether connected printers have Developer LAN Mode enabled by parsing the MQTT `fun` field (bit `0x20000000`). When any connected printer lacks developer mode, a persistent orange warning banner appears at the top of the UI with the affected printer name(s) and a link to Bambu Lab's documentation on how to enable it. Without developer mode, MQTT write operations (start/stop/pause prints, AMS control, light/speed/gcode commands) are silently rejected by newer firmware. The `developer_mode` state is included in the support bundle for diagnostics. New `/printers/developer-mode-warnings` endpoint provides a lightweight polling summary. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).

### Improved
- **Clear Plate Dot Indicator on Sidebar** — When the print queue is active and a printer finishes or fails with a pending next job, a small yellow dot now appears on the Printers sidebar icon to signal that user action (clearing the build plate) is needed. The indicator reuses the existing WebSocket-driven printer status cache, so no additional API polling is required. The dot disappears once the plate is cleared or the queue empties.
- **Inventory Sidebar Always Visible** — The Inventory sidebar item is no longer hidden when Spoolman is enabled. Instead, clicking it embeds the Spoolman web UI in the main content area via iframe (same approach as external links). When Spoolman is disabled, the internal inventory page is shown as before. Both modes use the same `/inventory` route and sidebar position.
- **Filament Override Test Coverage** — Added 11 backend unit tests: 6 for `_count_override_color_matches` (no status, exact match, no match, partial match, color normalization, external spool) and 5 for override application in filament matching (color override, tray_info_idx clearing, type change, partial override, nozzle filtering with override). Added 12 frontend tests for the `FilamentOverride` component: 5 rendering tests (null guards, slot display, dropdown count), 2 type filtering tests (same-type only, all colors), 3 nozzle filtering tests (extruder_id matching, single-nozzle passthrough, null extruder_id inclusion), and 2 interaction tests (select override, reset to original).
- **P2S Dual-AMS tray_now Test Coverage** — Added 14 integration tests for multi-AMS tray_now disambiguation on single-nozzle printers (resolving AMS-B slots via mapping field, AMS-A passthrough, multi-color mapping, ambiguous/missing mapping fallbacks, last_loaded_tray tracking). Added 9 unit tests for `_resolve_local_slot_from_mapping` (snow decoding, unmapped entry filtering, ambiguity detection, AMS-HT slot matching). All 66 tray_now-related tests pass.
- **Bulk Spool, Stock & Grouping Test Coverage** — Added 13 backend unit tests covering `SpoolBulkCreate` schema validation (quantity bounds, field preservation, stock vs configured distinction) and bulk endpoint logic (correct spool count, single quantity, identical fields). Added 29 frontend tests: 13 for `SpoolFormModal` covering `validateForm` with `quickAdd` flag (6 tests), quick-add toggle visibility, PA Profile tab hiding, quantity field gating (hidden by default, visible only in quick-add, hidden in edit mode), and brand/subtype optional asterisk removal in quick-add; 16 for inventory grouping logic covering `spoolGroupKey` identity/differentiation (7 tests) and `computeDisplayItems` grouping rules (9 tests for identical/different/used/assigned/single/order/mixed/empty scenarios).
- **Filament Cost Tracking Test Coverage** — Added 2 backend unit tests for archive cost aggregation (zero-cost guard preserves existing costs, positive-cost updates archive correctly). Added 2 frontend unit tests for spool form cost_per_kg persistence. Fixed missing `archive_id` database migration, SQLAlchemy `is None` → `.is_(None)` in where clauses, duplicate archive cost write, and unconditional zero-cost overwrite.
- **Spool Assignment Snapshot Test Coverage** — Added 7 backend unit tests covering spool assignment snapshotting at print start, snapshot-preferred spool lookup in both 3MF and AMS delta paths, fallback to live query for pre-upgrade sessions, and the core mid-print unlink scenario from #459.
- **Background Dispatch Test Coverage** — Added 5 backend unit tests for dispatch cancel races (single-lock TOCTOU fix), batch counter reset re-check, and job lifecycle. Added 2 FTP regression tests for voidresp error handling (upload-loop prevention) and A1 model voidresp skip. Added 1 frontend test for reprint toast suppression.
- **Tray Change Split Test Coverage** — Added 8 MQTT unit tests for `tray_change_log` lifecycle (default empty, seed on print start, clear on new print, record during RUNNING/PAUSE, ignore during IDLE, deduplicate, multi-change history). Added 6 usage tracker unit tests for weight splitting (per-layer gcode split, linear fallback, no-change normal path, empty log recovery, missing spool skip, triple segment split).
- **Developer Mode Detection Test Coverage** — Added 7 backend unit tests for MQTT `fun` field parsing (bit clear/set detection, exact bit check, invalid hex handling, state persistence across messages). Added 4 frontend tests for the warning banner (single/multiple printer names, hidden when empty, "How to enable" link).
- **Frontend Pre-Commit Hooks** ([#458](https://github.com/maziggy/bambuddy/issues/458)) — Added `frontend-typecheck` (`tsc --noEmit`) and `frontend-lint` (`eslint .`) hooks to the pre-commit config. Both hooks only trigger when `frontend/src/**/*.{ts,tsx}` files are staged.

## [0.2.1b] - 2026-02-19

### Fixed
- **PAUSED State Never Matched** ([#447](https://github.com/maziggy/bambuddy/issues/447)) — Removed dead `PAUSED` checks across frontend and backend. The printer only sends `PAUSE` via MQTT `gcode_state`, so `PAUSED` comparisons were unreachable code.
- **Nozzle Mapping Uses Wrong Source in 3MF Files** — The `extract_nozzle_mapping_from_3mf()` function used `filament_nozzle_map` (user preference) as the primary source for nozzle assignments. BambuStudio's "Auto For Flush" mode overrides user preferences at slice time, so the actual assignment lives in the `group_id` attribute on `<filament>` elements in `slice_info.config`. Now uses `group_id` as the primary source and falls back to `filament_nozzle_map` only when `group_id` is not present.
- **Print Scheduler Hard-Filters Nozzle When No Trays on Target Nozzle** — On dual-nozzle printers, the scheduler enforced a strict nozzle filter when matching filaments. If a slicer filament was assigned to a nozzle with no AMS trays (e.g., only external spool on left nozzle), the match failed even though the filament existed on the other nozzle. Now falls back to unfiltered matching when no trays exist on the target nozzle.
- **Print Scheduler External Spool Ignores Nozzle Assignment** — The external spool fallback in the scheduler always mapped to extruder 0 (right), ignoring the slicer's nozzle assignment. Now uses the 3MF nozzle mapping to select the correct extruder for external spool matches.
- **ams_extruder_map Race Condition on Printer Status API** — The `/printers/{id}/status` endpoint read `ams_extruder_map` from the MQTT state without checking if the AMS data had been received yet. On fresh connections before the first AMS push-all, this returned an empty map — causing the frontend nozzle filter to show all trays as unfiltered. Now returns an empty object gracefully and the frontend disables nozzle filtering until the map is populated.
- **Filament Mapping Frontend Ignores Nozzle for External Spools** — The `useFilamentMapping` hook always set `extruder_id: 0` for external spool matches. Now uses the nozzle mapping from the 3MF file to determine the correct extruder.
- **AMS-HT Global Tray ID Computed Wrong on Printer Card** — The PrintersPage computed AMS-HT tray IDs using `ams_id * 4 + slot` (giving 512+), but AMS-HT units use their raw `ams_id` (128-135) as the global tray ID. Now uses `ams_id` directly for AMS-HT units.
- **Filament Mapping Dropdown Shows Wrong Nozzle Trays** — The FilamentMapping dropdown filtered by `extruder_id` using strict equality, but `extruder_id` could be `undefined` for printers that hadn't reported their AMS extruder map yet. This caused all trays to be hidden. Now skips nozzle filtering when `extruder_id` is undefined.
- **Cancelled Print Usage Tracking Uses Stale Progress/Layer** — When a print was cancelled, the usage tracker read `mc_percent` and `layer_num` from the printer's MQTT state — but by the time the `on_print_complete` callback ran, the printer had already reset these to 0. Now captures the last valid progress and layer values during printing, and the usage tracker reads these captured values on cancellation for accurate partial usage.
- **H2D Tray Disambiguation Triggers on Single-Nozzle Printers** — The `tray_now <= 3` check for H2D dual-nozzle disambiguation matched any printer loading from AMS 0 (trays 0-3). On P2S, X1C, and X1E with multiple AMS units, this caused warning log spam every second. Now uses a persistent `_is_dual_nozzle` flag detected from `device.extruder.info` (>= 2 entries), which only dual-nozzle printers (H2D, H2D Pro) report.
- **AMS-HT Snow Slot Mismatch Log Spam on H2D** — The snow-based tray_now disambiguation computed `snow_slot = -1` for AMS-HT trays (IDs 128-135), causing a "slot mismatch" debug log on every MQTT update even though the result was correct. Now correctly computes `snow_slot = 0` for AMS-HT single-slot units.
- **H2D Tray Disambiguation Produces Bogus tray_now for AMS-HT** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — When the snow field hadn't arrived yet on H2D dual-nozzle printers, the `ams_extruder_map` fallback computed `ams_id * 4 + slot` for all AMS types — including AMS-HT units (IDs 128-135) which have a single slot and use their unit ID as the global tray ID. This produced bogus values like 512+ that briefly appeared in the UI and could pollute `last_loaded_tray`. Now correctly returns the AMS-HT unit ID for single-slot units, handles AMS-HT in multi-AMS matching, filters AMS-HT candidates when slot > 0, and tightens `last_loaded_tray` to only accept physically valid tray IDs (0-15, 128-135, 254).
- **Color Tooltip Clipped Behind Adjacent Swatches** — Color swatch hover tooltips in the spool form were rendered behind neighboring swatches due to missing z-index on the hover state. Added `hover:z-20` and tooltip `z-20` classes.
- **Print Queue Shows UUID Hash Instead of Filename** ([#438](https://github.com/maziggy/bambuddy/issues/438)) — When printing a library file, the Print Queue and archive displayed the UUID-hex disk filename (e.g., `c65887535303404eba1525176a0f78dc`) instead of the original human-readable name. Library files are stored on disk with UUID filenames for uniqueness, but `archive_print()` used the disk path as the display name. Now passes the original `LibraryFile.filename` through to `archive_print()` from both the print scheduler and the direct-print-from-library flow, so the archive's `filename`, `print_name`, and directory name all use the human-readable name.

- **Usage Tracking Wrong Spool on Dual-Nozzle / Multi-AMS Printers** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — On H2C, H2D Pro, and other dual-nozzle printers with multiple AMS units, the usage tracker attributed filament consumption to the wrong spools. The MQTT `mapping` field — a per-print array that maps slicer filament slots to physical AMS trays — was preserved in state but never parsed or used. The tracker fell back to `slot_id - 1` as the global tray ID, which is incorrect when AMS hardware IDs differ from sequential indices (e.g., AMS-HT units with ID 128). Now decodes the MQTT mapping field from its snow encoding (`ams_hw_id * 256 + local_slot`) into bambuddy global tray IDs and uses it as a universal mapping source — working for all printer models and all print sources (slicer, queue, reprint) without relying on `tray_now` disambiguation. For printers that don't provide the MQTT mapping field (A1, A1 Mini, P1S, P2S), a color-matching fallback compares 3MF filament slot colors against AMS tray colors to resolve the correct slot-to-tray mapping. Gracefully returns no match when colors are ambiguous (duplicate tray colors) or unavailable.
- **AMS Slot Config: PFUS Preset IDs Cause Slicer to Reset Slots** — When assigning a spool with a user-local `PFUS*` preset ID (from BambuStudio's custom filament profiles), the slicer didn't recognize the ID and actively reset the AMS slot configuration. Now replaces `PFUS*` IDs with generic Bambu filament IDs (e.g., `GFL99` for PLA). When the slot already has a recognized cloud-synced preset for the same material (e.g., `P4d64437`), it is reused to preserve K-profile calibration associations. Applies to both the slot configure endpoint and the inventory spool assignment flow.
- **Fill Level Bar Missing for Brand New Spools** — Spools with `weight_used = 0` (brand new, never printed) showed no fill level bar on the printer card. The condition checked `weight_used > 0` instead of `weight_used != null`, excluding zero-usage spools. Now correctly shows 100% fill for new spools while still hiding the bar when weight data is unavailable (`null`).
- **npm audit: suppress moderate ajv ReDoS finding** — Added `audit-level=high` to `frontend/.npmrc` so `npm audit` exits cleanly. The ajv@6 ReDoS (GHSA-2g4f-4pwh-qvx6) is a transitive dependency of eslint@9 with no patched v6 release; ajv@8 override breaks eslint. The vulnerability requires crafted `$data` schema input — not an attack vector in a linting config.
- **npm audit: fix minimatch ReDoS finding** — Added an npm override for `minimatch@^10.2.1` in `package.json` to resolve the high-severity ReDoS (GHSA-3ppc-4f35-3m26) affecting minimatch@3.x/9.x pulled in transitively by eslint@9, typescript-eslint, and @vitest/coverage-v8. Eslint@9 pins minimatch@3.x with no patched release; eslint@10 upgrades to minimatch@10 but is not yet available. The override forces the patched version across the tree. Verified lint, build, and all tests pass.
- **Spool Form Allows Empty Brand & Subtype** ([#417](https://github.com/maziggy/bambuddy/issues/417)) — The spool add/edit modal did not require Brand or Subtype fields, allowing spools to be saved without them. When such a spool was assigned to an AMS slot, the `tray_sub_brands` sent to the printer was incomplete (e.g., just "PETG" instead of "PETG Basic"), causing BambuStudio to not recognize the filament profile. Brand and Subtype are now mandatory fields with validation errors shown on submit.
- **Open in Slicer Fails When Authentication Enabled** ([#421](https://github.com/maziggy/bambuddy/issues/421)) — The "Open in Slicer" buttons for BambuStudio and OrcaSlicer failed with "importing failed" when authentication was enabled. Slicer protocol handlers (`bambustudio://`, `orcaslicer://`) launch the slicer app which fetches the file via HTTP — but cannot send authentication headers, so the global auth middleware returned 401. Additionally, the URL format was wrong on Linux (used the macOS-only `bambustudioopen://` scheme instead of `bambustudio://open?file=`). Fixed with short-lived, single-use download tokens: the frontend fetches a token via an authenticated POST endpoint, then builds a `/dl/{token}/{filename}` URL that the slicer can access without auth headers. The token is validated server-side (5-minute expiry, single-use). Platform-specific URL formats now match the actual slicer source code: macOS uses `bambustudioopen://` with URL encoding, Windows/Linux use `bambustudio://open?file=`, and OrcaSlicer uses `orcaslicer://open?file=`.

### New Features
- **Multiple Virtual Printers** — Run multiple virtual printers per Bambuddy installation. Each virtual printer gets a dedicated bind IP address with completely independent FTP, MQTT, SSDP, and Bind servers — no shared services or SNI routing. Full CRUD API (`/api/virtual-printers`) and React UI for creating, editing, and deleting virtual printers. Each instance supports all four modes (Immediate, Review, Print Queue, Proxy), any of the 11 supported printer models, per-instance TLS certificates (shared CA), and individual network interface override. Database-backed with auto-incremented serial suffixes.
- **Virtual Printer: Dual Bind/Detect Ports** ([#445](https://github.com/maziggy/bambuddy/issues/445)) — The slicer bind/detect handshake now listens on both ports 3000 and 3002. Different BambuStudio/OrcaSlicer versions use different ports for this handshake, so Bambuddy accepts connections on either. Applies to both server mode (BindServer) and proxy mode (SlicerProxyManager).
- **Clear Plate Permission** ([#446](https://github.com/maziggy/bambuddy/issues/446)) — New `printers:clear_plate` permission allows admins to grant users the ability to confirm a plate is cleared for the next queued print without granting full `printers:control` (which also allows stopping prints, configuring AMS, toggling lights, etc.). Existing groups with `printers:control` automatically receive the new permission on startup. The Operators default group includes it by default.
- **Full-Page Group Permission Editor** ([#446](https://github.com/maziggy/bambuddy/issues/446)) — Replaced the cramped permission modal with a dedicated full-page editor at `/groups/:id/edit`. Features a responsive 2-column grid of always-expanded category cards, permission search/filtering, Select All / Clear All bulk actions, category-level checkboxes with partial state, and a fixed bottom action bar. The old `GroupsPage.tsx` dead code has been removed.

### Changed
- **Filament Catalog API Renamed** ([#427](https://github.com/maziggy/bambuddy/issues/427)) — Renamed `/api/v1/filaments/` to `/api/v1/filament-catalog/` to avoid confusion with the inventory spools page (labeled "Filament" in the UI). The old endpoint managed material type definitions (cost, temperature, density), not physical spools — the shared name caused users to expect the API to return their spool inventory.

### Improved
- **AMS Mapping Test Coverage** — Added 63 backend tests for scheduler AMS mapping (nozzle filtering, external spool extruder assignment, fallback behavior) and 43 frontend tests for `useFilamentMapping` hook (nozzle-aware matching, AMS-HT handling, external spool extruder logic).
- **Tray Now Disambiguation Test Coverage** — Added 28 MQTT message replay tests covering all `tray_now` disambiguation paths: single-nozzle passthrough (X1E/P2S), H2D dual-nozzle snow field, pending target, `ams_extruder_map` fallback, active extruder switching, and full multi-color print lifecycles.
- **Tray Info Idx Resolution Test Coverage** — Added 12 backend integration tests for PFUS→generic tray_info_idx resolution across both the slot configure and inventory assignment endpoints, plus 10 frontend unit tests for the fill level calculation logic.


## [0.2.0] - 2026-02-17

### New Features
- **Bed Cooled Notification** ([#378](https://github.com/maziggy/bambuddy/issues/378)) — New notification event that fires when the print bed cools below a configurable threshold (default 35°C) after a print completes. Useful for knowing when it's safe to remove parts. A background task polls the bed temperature every 15 seconds after print completion and sends a notification when it drops below the threshold. Automatically cancels if a new print starts or the printer disconnects. The threshold is configurable in Settings → Notifications. Includes a customizable notification template with printer name, bed temperature, and threshold variables.
- **Spool Inventory — AMS Slot Assignment** — Assign inventory spools to AMS slots for filament tracking. Hover over any non-Bambu-Lab AMS slot to assign or unassign spools. The assign modal filters out Bambu Lab spools (tracked via RFID) and spools already assigned to other slots. Bambu Lab spool slots automatically hide assign/unassign UI since they are managed by the AMS. When a Bambu Lab spool is inserted into a slot with a manual assignment, the assignment is automatically unlinked.
- **Spool Inventory — Remaining Weight Editing** — Edit the remaining filament weight when adding or editing a spool. The new "Remaining Weight" field in the Additional section shows current weight (label weight minus consumed) with a max reference. Edits are stored as `weight_used` internally.

- **Spool
- Inventory — Unified 3MF-Based Usage Tracking** ([#336](https://github.com/maziggy/bambuddy/issues/336)) — All spools (Bambu Lab and third-party) now use 3MF slicer estimates as the primary tracking source. Per-filament `used_g` data from the archived 3MF file provides precise per-spool consumption. For failed or aborted prints, per-layer G-code analysis provides accurate partial usage up to the exact failure layer, with linear progress scaling as fallback. AMS remain% delta is the final fallback for G-code-only prints without an archived 3MF. Slot-to-tray mapping uses queue `ams_mapping` for queue-initiated prints and the printer's `tray_now` state for single-filament non-queue prints, ensuring the correct physical spool is always tracked.
- **Notification Templates — Filament Usage Variables** ([#336](https://github.com/maziggy/bambuddy/issues/336)) — `print_complete`, `print_failed`, and `print_stopped` notification events now expose `{filament_grams}` (total grams, scaled by progress for partial prints), `{filament_details}` (per-filament breakdown with AMS slot info, e.g. "AMS-A T1 PLA: 12.4g | AMS-A T3 PETG: 2.8g"), and `{progress}` (completion percentage for failed/stopped prints). The `{filament_details}` variable includes the AMS unit and tray position for each filament used, with "Ext" shown for external spool holders. Falls back to type-only format (e.g. "PLA: 10.0g") when usage tracking data is unavailable. Webhook payloads include `filament_used`, `filament_details`, and `progress` fields. Per-slot filament data is stored in archive `extra_data` for downstream use.
- **Printer Status Summary Bar — Next Available & Availability Count** ([#354](https://github.com/maziggy/bambuddy/issues/354)) — The status bar on the Printers page now shows an availability count ("X available") alongside the printing/offline counts, and a "Next available" indicator showing which printing printer will finish soonest — with printer name, mini progress bar, completion percentage, and remaining time. Useful for print farms to quickly identify the next free printer. Updates in real-time via WebSocket. Translated in all 4 locales (en, de, ja, it).
- **Nozzle-Aware AMS Filament Mapping for Dual-Nozzle Printers** ([#318](https://github.com/maziggy/bambuddy/issues/318)) — On dual-nozzle printers (H2D, H2D Pro), each AMS unit is physically connected to either the left or right nozzle. Bambuddy now reads nozzle assignments from the 3MF file (`filament_nozzle_map` + `physical_extruder_map` in `project_settings.config`) and constrains filament matching to only AMS trays connected to the correct nozzle via `ams_extruder_map`. Applies to the print scheduler, reprint modal, queue modal, and multi-printer selection. Falls back gracefully to unfiltered matching when no trays exist on the target nozzle. The filament mapping UI shows L/R nozzle badges for dual-nozzle prints. Translated in all 4 locales (en, de, ja, it).
- **Dual External Spool Support for H2D** — H2-series printers with two external spool holders (Ext-L and Ext-R) are now fully supported. The external spool section renders as a grid with both slots, each showing filament type, color, fill level, and hover card details. Previously only a single external spool was displayed. Applies to the printer card, filament mapping, print scheduler, usage tracking, and inventory assignment. The `vt_tray` field is now an array across the entire stack (MQTT, API, WebSocket, frontend).
- **AMS Slot Configuration — Model Filtering & Pre-Population** — The Configure AMS Slot modal now filters filament presets by the connected printer model. Only presets matching the printer (e.g., "@BBL X1C" presets for X1C printers) and generic presets without a model suffix are shown. Local presets are filtered by their `compatible_printers` field. When re-configuring an already-configured slot, the modal pre-selects the saved preset, pre-populates the color, and auto-selects the active K-profile. The preset list auto-scrolls to the selected item. All modal strings are now fully translated in 5 locales (en, de, fr, it, ja).
- **K-Profiles View — Accurate Filament Name Resolution** — K-profile filament names are now resolved from builtin filament tables and user cloud presets (via new `/cloud/filament-id-map` endpoint) instead of showing raw IDs like "GFU99" or "P4d64437". Falls back to extracting names from the profile name field.
- **Print Log** — New view mode on the Archives page showing a chronological table of all print activity. Columns include date/time, print name, printer, user, status, duration, and filament. Supports filtering by search text, printer, user, status, and date range. Pagination with configurable page size. A dedicated clear button deletes only log entries without affecting archives. Data is stored in a separate `print_log_entries` database table.
- **Sync Spool Weights from AMS** — New button in Settings → Filament Tracking (built-in inventory mode) to force-sync all inventory spool weights from the live AMS remain% values of connected printers. Overwrites the database weight data with current sensor readings. Useful for recovering from corrupted weight data (e.g., after a power-off event zeroed all fill levels). Requires printers to be online. Includes a confirmation modal.
- **Notification Thumbnails for Telegram & ntfy** ([#372](https://github.com/maziggy/bambuddy/issues/372)) — Print thumbnail images are now attached to Telegram and ntfy notifications (previously only Pushover and Discord). Telegram uses the `sendPhoto` API with the image as caption attachment. ntfy sends the image as a binary PUT with `Filename` and `Message` headers. No configuration needed — images are sent automatically when available.
- **Clear HMS Errors** — New "Clear Errors" button in the HMS error modal sends a `clean_print_error` MQTT command to dismiss stale `print_error` values that persist after print cancellation or transient events. Locally clears the error list for immediate UI feedback. Permission-gated to `printers:control`. The button only appears when there are active errors.

### Fixed
- **Firmware Upload Uses Wrong Filename on Cache Hit** — The firmware update uploader cached downloaded firmware files under a mangled name (e.g., `X1C_01_09_00_10.bin`) instead of the original filename from Bambu Lab's CDN. On the first download the correct filename was uploaded to the SD card, but on subsequent attempts the cached file with the wrong name was used — causing the printer to not recognize the firmware file. Now caches using the original filename so the SD card always receives the correct file.
- **Update Check Runs When Disabled** ([#367](https://github.com/maziggy/bambuddy/issues/367)) — The Settings page triggered an update check on every visit even when "Check for updates" was disabled, causing error popups on air-gapped systems with no internet. The backend `/updates/check` endpoint also ignored the setting entirely. Now the backend returns early without making GitHub API calls when the setting is disabled, the Settings page respects the `check_updates` flag before auto-fetching, and the printer card firmware badge shows a neutral version-only display instead of disappearing when firmware update checks are off.
- **Stale Inventory Assignments Persist After Switching to Spoolman Mode** — When switching from built-in inventory to Spoolman mode, existing spool-to-AMS-slot assignments were not cleaned up. The printer card hover cards continued showing "Assign Spool" buttons that opened the internal inventory modal, and any prior assignments remained visible. Now bulk-deletes all `SpoolAssignment` records when enabling Spoolman, invalidates the frontend cache so printer cards update immediately, and hides the inventory assign/unassign UI on printer cards while in Spoolman mode.
- **Bulk Archive Delete Leaves Orphaned Database Records** — When bulk-deleting archives, the files were removed from disk before the database commit. If concurrent SQLite writes caused a lock timeout, the commit failed and rolled back — leaving database records pointing to deleted files (broken thumbnails, 404 errors). Fixed by deleting the database record first and only removing files after a successful commit.
- **Model-Specific Maintenance Tasks for Carbon Rods vs Linear Rails** ([#351](https://github.com/maziggy/bambuddy/issues/351)) — Maintenance tasks "Clean Carbon Rods" and "Lubricate Linear Rails" were shown for all printers regardless of motion system. H2 and A1 series use linear rails (not carbon rods), and X1/P1/P2S series use carbon rods (not linear rails). Maintenance types are now classified by rod/rail type: "Lubricate Carbon Rods" and "Clean Carbon Rods" for X1/P1/P2S, "Lubricate Linear Rails" and "Clean Linear Rails" for A1/H2. Stale and duplicate system types are automatically cleaned up on startup. Includes model-specific wiki links and i18n keys for all 4 locales.
- **AMS Slot Configuration Overwritten on Startup** — Bambuddy was resetting AMS slot filament presets on every startup and reconnection. The `on_ams_change` callback unconditionally unlinked Bambu Lab spool assignments on each MQTT push-all response, then re-assigned them by sending `ams_filament_setting` without a `setting_id`, which cleared the printer's filament preset. Now compares spool RFID identifiers (`tray_uuid` / `tag_uid`) before unlinking — if the same spool is still in the slot, the assignment is preserved and no `ams_filament_setting` command is sent.
- **Bambu Lab Spool Detection False Positives** — The `is_bambu_lab_spool()` function (backend) and `isBambuLabSpool()` (frontend) incorrectly identified third-party spools as Bambu Lab spools when they used Bambu generic filament presets (e.g., "Generic PLA"). The `tray_info_idx` field (e.g., "GFA00") identifies the filament *type*, not the spool manufacturer — third-party spools using Bambu presets also have GF-prefixed values. Removed `tray_info_idx` from detection logic; now uses only hardware RFID identifiers (`tray_uuid` and `tag_uid`) which are physically embedded in genuine Bambu Lab spools.
- **FTP Disconnect Raises EOFError When Server Dies** — `BambuFTPClient.disconnect()` only caught `OSError` and `ftplib.Error`, but `quit()` raises `EOFError` when the server has closed the connection mid-session. `EOFError` is not a subclass of either, so it propagated to callers. Now caught alongside the other exception types for clean best-effort disconnect.
- **RFID Spool Data Erased by Periodic AMS Updates** — Periodic MQTT push-all responses cleared `tag_uid` and `tray_uuid` fields because they were included in the "always update" list. These fields are now preserved during updates and only cleared when a spool is physically removed (slot clearing detected by empty `tray_type`). This fixes the AMS "eye" icon disappearing for RFID spools after startup.
- **AMS Slot Configuration Overwrites RFID Spool State** — Configuring an AMS slot for an RFID-detected Bambu Lab spool sent `ams_set_filament_setting`, which replaced the firmware's RFID-managed filament config with a manual one — causing the slicer's "eye" icon to change to a "pen" icon. Now detects RFID spools and skips the filament setting command, only sending K-profile selection.
- **K-Profile Selection Corrupts Existing Profiles on X1C/P1S** — The `extrusion_cali_sel` command included a `setting_id` field that BambuStudio never sends, causing firmware to mislink calibration data. The `extrusion_cali_set` command was sent unconditionally, overwriting existing profile metadata. Now `setting_id` is removed from selection commands, and `extrusion_cali_set` is only sent when no existing profile is selected (`cali_idx < 0`).
- **AMS Slot Configure — Black Filament Color Not Pre-Populated** — When re-opening the Configure AMS Slot modal for a slot with black filament, the color field was empty despite the preset and K-profile being correctly pre-selected. The color pre-population logic excluded hex `000000` (black) as a guard against empty slots, but empty slots already skip color data entirely. Removed the unnecessary check so black is now pre-populated like any other color.
- **Archive List View Not Labeling Failed Prints** ([#365](https://github.com/maziggy/bambuddy/issues/365)) — The archive grid view displayed a red "Failed" / "Cancelled" badge on failed and aborted prints, but the list view had no equivalent indicator. Now shows an inline status badge next to the print name in list view.
- **Reprint Fails with SD Card Error for Archives Without 3MF File** ([#376](https://github.com/maziggy/bambuddy/issues/376)) — When a print was sent from an external slicer and Bambuddy couldn't download the 3MF from the printer during auto-archiving, the fallback archive had no file. Attempting to reprint such an archive tried to upload the data directory as a file, causing a confusing "SD card error." The backend now returns a clear error for file-less archives, and the frontend disables Print/Schedule/Open in Slicer buttons with a tooltip explaining that the 3MF file is unavailable.
- **Inventory Spool Weight Resets After Print Completes** — After a print, the usage tracker correctly updated `weight_used` (e.g., +1.6g), but periodic AMS status updates recalculated `weight_used` from the AMS remain% sensor and overwrote the precise value. For small prints on large spools (e.g., 1.6g on 1000g), the AMS remain% stays at 100% (integer resolution = 10g steps), resetting `weight_used` back to 0. The AMS weight sync now only increases `weight_used`, never decreases it, preserving precise values from the usage tracker.
- **All Spool Fill Levels Drop to Zero When Printers Power Off** — When a printer powers off, the AMS sensor can report `remain=0` for all trays while `tray_type` is still populated. The weight sync treated 0% remain as "100% consumed," computing `weight_used = label_weight` (e.g., 1000g). The "only increase" guard passed because `label_weight > current_used + 1`, marking every assigned spool as fully consumed. The AMS weight sync now skips `remain=0` entirely — a physically empty spool is tracked by the usage tracker during the print, not by a transient AMS sensor reading.
- **Spool Edit Form Overwrites Usage-Tracked Weight** — Editing any spool field (note, color, material, etc.) sent the full form data back to the server, including `weight_used`. If the frontend cache was stale (e.g., loaded before the last print completed), saving the form would silently reset `weight_used` to the pre-print value, reverting the remaining weight to full. The form now only includes `weight_used` in the update request when the user explicitly changes the weight field.
- **K-Profile Auto-Select Fails for Non-BL Spools on Dual-Nozzle Printers** — When assigning a third-party spool to an AMS slot on dual-nozzle printers (H2D, H2D Pro), the MQTT auto-configure step crashed with `'SpoolKProfile' object has no attribute 'extruder_id'`. The K-profile model uses `extruder` (not `extruder_id`). Fixed the attribute name so K-profile matching correctly filters by nozzle on dual-extruder printers.
- **Loose Archive Name Matching Could Cause Wrong Archive Reuse** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — The `on_print_start` callback used `ilike('%{name}%')` to find existing "printing" archives, which meant a print named "Clip" could incorrectly match "Cable Clip" or "Clip Stand". This could cause a new print to reuse the wrong archive or skip creating one. Tightened to exact `print_name` match or exact filename variants (`.3mf`, `.gcode.3mf`).
- **Phantom Prints on Power Cycle** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — The print queue uploaded `.3mf` files to the printer's SD card root (`/`) but never deleted them after the print finished. Some printers (e.g. P1S) auto-start files found in the root directory on power cycle, causing ghost prints on every reboot. Now deletes the uploaded file from the SD card after print completion (best-effort, non-blocking). The cleanup also tries `.gcode` files and retries up to 3 times with a 2-second delay to handle printers that briefly lock the filesystem after a print ends. Runs before the archive lookup so it works even when auto-archiving is disabled.
- **Queue Items Stuck in "Printing" After Print Completes** — The queue item status update (from `printing` to `completed`/`failed`) was placed after an early return that exits when the archive record cannot be found. If the archive lookup failed (e.g. app restart mid-print, manual archive deletion), the function returned early and the queue item stayed in `printing` forever. Over multiple print cycles, stale items accumulated — causing the "Printing" count to show double the actual printers and completed prints to remain in the "Currently Printing" section. Moved the queue item status update (including MQTT relay notification, queue-completed notification, and auto-power-off) to before the archive lookup early return so it always runs.
- **Spool Form Scrollbar Flicker in Edge** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — The Add/Edit Spool modal's scrollable area used `overflow-y: auto`, which on Windows Edge (where scrollbars take layout space) caused the scrollbar to appear and disappear on hover — making the color picker unusable at certain zoom levels. Added `scrollbar-gutter: stable` to reserve scrollbar space and prevent layout thrashing.
- **Archive Duplicate Badge Misses Name-Based Duplicates** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — The duplicate badge on archive cards only matched by file content hash, so re-sliced prints of the same model (different GCODE, same print name) were not flagged as duplicates. Now also matches by print name (case-insensitive), consistent with the detail view's duplicate detection.
- **Schedule Print Allows No Plate Selected for Multi-Plate Files** ([#394](https://github.com/maziggy/bambuddy/issues/394)) — When scheduling a multi-plate file from the file manager, the modal showed a "Selection required" warning but still allowed submission without selecting a plate. The job defaulted to plate 1, but the queue item didn't indicate which plate, and editing showed no plate selected. Now auto-selects the first plate by default when plates load, and the submit button validation applies to both archive and library files.
- **3MF Usage Tracking Broken for Queue Prints from File Manager** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — When a print was queued from the file manager (library file), the scheduler did not create an archive or register the expected print. The `on_print_start` callback had to re-download the 3MF from the printer via FTP, and if that failed, a fallback archive was created without the 3MF file — making 3MF-based filament usage tracking impossible. The queue item's `archive_id` also remained NULL, so the usage tracker could not find the queue's AMS slot mapping for correct spool resolution. The scheduler now creates an archive from the library file before uploading, links it to the queue item, and registers it as an expected print — matching the behavior of the direct library print route.
- **Printer Queue Widget Shows "Archive #null" for File Manager Prints** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — The "Next in queue" widget on the printer card only checked `archive_name` and `archive_id` when displaying the queued item name. Queue items from the file manager have `library_file_name` and `library_file_id` instead, so the widget displayed "Archive #null". Now falls back to `library_file_name` and `library_file_id`, matching the Queue page display logic.
- **Inventory Usage Not Tracked for Remapped AMS Slots** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — When reprinting an archive with a different AMS slot mapping (e.g. changing from slot A1 to C4 in the mapping modal), the usage tracker used the default 3MF slot-to-tray mapping instead of the actual mapping from the print command. The `ams_mapping` from reprint, library print, and queue print commands is now stored and used as the highest-priority mapping source for usage tracking.
- **Inventory Usage Not Tracked for Slicer-Initiated Prints on H2D** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — On H2D printers, the AMS `tray_now` field is always 255 in MQTT data. The actual tray is resolved via the snow field ~44 seconds after print start, but reverts to "unloaded" when the AMS retracts filament at completion. The usage tracker now tracks `last_loaded_tray` — the last valid tray seen during printing — as a fallback when both `tray_now` at start and at completion are invalid. Also captures `tray_now` at print start for printers that report a valid value before the RUNNING state.
- **Inventory Usage Wrong Tray for Slicer-Initiated Prints** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — When a print was started from an external slicer (BambuStudio, OrcaSlicer, Bambu Handy), Bambuddy never saw the `ams_mapping` the slicer sent, because it only subscribed to the printer's report topic. The usage tracker fell back to `tray_now` which could resolve to the wrong AMS tray (e.g., Black PLA at A2 instead of Green PLA at A4 on H2D Pro). Now subscribes to the MQTT request topic to intercept print commands from any source, capturing the `ams_mapping` universally — regardless of who starts the print. The request topic subscription is fail-safe: if the printer's MQTT broker rejects it (e.g., P1S), Bambuddy detects the rejection via SUBACK or disconnect timing and gracefully disables the subscription for that printer, falling back to the existing `tray_now`-based tracking without breaking the MQTT connection.
- **P1S Timelapse Not Detected — AVI Format Support** ([#405](https://github.com/maziggy/bambuddy/issues/405)) — P1-series printers save timelapse videos as `.avi` (MJPEG), but the timelapse scanner only looked for `.mp4` files — so P1S timelapses were never found or attached to archives. Now discovers both `.mp4` and `.avi` timelapse files across all FTP directories (`/timelapse`, `/timelapse/video`, `/record`, `/recording`). AVI files are saved immediately and converted to MP4 in a non-blocking background task using FFmpeg with `-threads 1` and `nice -n 19` to minimize CPU impact on Raspberry Pi. If FFmpeg is unavailable, the AVI is served as-is with the correct MIME type. The manual "Scan for Timelapse" route also searches the additional directories used by P1-series printers.
- **Timelapse Upload & Remove** ([#406](https://github.com/maziggy/bambuddy/issues/406)) — When the auto-scan attaches the wrong timelapse (e.g., from a different print), there was no way to remove it or attach the correct one. Added "Upload Timelapse" and "Remove Timelapse" context menu items. Upload accepts `.mp4`, `.avi`, and `.mkv` files (non-MP4 auto-converted in background). Remove deletes the file and clears the database reference. Both actions are permission-gated and available in grid and list views.
- **Spool Assignments Falsely Unlinked After Print Due to Color Variation** — The auto-unlink logic compared AMS tray colors against saved fingerprints using exact hex match. RFID sensors report slightly different color values across reads (e.g. `7CC4D5FF` vs `56B7E6FF` for the same spool, Euclidean distance ~43.6). Now uses a color similarity function with a tolerance threshold of 50, preventing false unlinks from minor RFID/firmware color variations while still detecting genuinely different spools.

### Improved
- **Virtual Printer: Dual Bind/Detect Ports 3000 + 3002** ([#445](https://github.com/maziggy/bambuddy/issues/445)) — BambuStudio/OrcaSlicer require a bind/detect handshake before connecting via MQTT/FTP. Different slicer versions use port 3000 or 3002, so the BindServer and proxy now listen on both ports for full compatibility. Docker users in bridge mode need to expose both (`-p 3000:3000 -p 3002:3002`).
- **Usage Tracking Diagnostic Logging** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — Added INFO-level logging at print start and completion that dumps the printer's MQTT `mapping` field, `tray_now`, `last_loaded_tray`, all mapping-related raw data keys, and per-AMS-tray summaries (type, color, tray_now, tray_tar). Enables investigating the slot-to-tray mapping behavior across different printer models (X1E, H2D Pro, P1S, etc.) without requiring DEBUG mode.
- **Skip Objects: Click-to-Enlarge Lightbox** ([#396](https://github.com/maziggy/bambuddy/issues/396)) — The skip objects modal's small 208px image panel made it difficult to distinguish object markers when parts are small or close together. Clicking the image now opens a fullscreen lightbox overlay with the same image and markers at a much larger size (up to 600px). The 24px marker circles are proportionally smaller relative to the enlarged image, solving the overlap problem. Close via X button, Escape key, or clicking the backdrop. Escape cascades correctly — closes lightbox first, then the modal.
- **Phantom Print Investigation — Logging & Hardening** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — Added targeted logging and hardening to help diagnose reports of prints starting automatically without user input. Debug log volume reduced ~90% by suppressing `sqlalchemy.engine` (changed from INFO to WARNING) and `aiosqlite` (new WARNING suppression) noise that previously filled 2.5MB in 16 minutes. Every `start_print()` call now logs a `PRINT COMMAND` trace with the caller's file, line, and function name. The print scheduler logs pending queue items when found. `on_print_complete` warns when multiple queue items are in "printing" status for the same printer, which signals a state inconsistency.
- **Reduce Log Noise from MQTT Diagnostics** ([#365](https://github.com/maziggy/bambuddy/issues/365)) — Downgraded 58 high-frequency MQTT diagnostic messages from INFO to DEBUG level. Payload dumps, detector state changes, field discovery logs, H2D disambiguation, and periodic status updates no longer flood the log at the default INFO level. Also suppresses paho-mqtt library INFO messages in production. User-initiated actions (print start/stop, AMS load/unload, calibration) remain at INFO. All diagnostic detail is still available when debug logging is enabled.
- **SQLite WAL Mode for Database Reliability** — Database now uses Write-Ahead Logging (WAL) mode with a 5-second busy timeout, reducing "database is locked" errors under concurrent access. WAL mode allows simultaneous reads during writes, improving responsiveness for multi-printer setups. Automatically enabled on startup.
- **External Camera Not Used for Snapshot + Stream Dropping** ([#325](https://github.com/maziggy/bambuddy/issues/325)) — The snapshot endpoint (`/camera/snapshot`) always used the internal printer camera even when an external camera was configured. Now checks for external camera first, matching the existing stream endpoint behavior. Also fixed external MJPEG and RTSP streams silently dropping every ~60 seconds due to missing reconnect logic — the underlying stream generators exit on read timeout, and the caller now retries up to 3 times with a 2-second delay instead of ending the stream.
- **H2C Nozzle Rack Text Unreadable on Light Filament Colors** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle rack slots use the loaded filament color as background, but white/light filaments made the white "0.4" text nearly invisible. Now uses a luminance check to switch to dark text on light backgrounds.
- **File Downloads Show Generic Filenames** ([#334](https://github.com/maziggy/bambuddy/issues/334)) — Downloaded files with special characters in their names (spaces, umlauts, parentheses) were saved as generic `file_1`, `file_2` instead of the original filename. The `Content-Disposition` header parser now handles RFC 5987 percent-encoded filenames (`filename*=utf-8''...`) used by FastAPI for non-ASCII characters. Fix applied to all download endpoints (library files, archives, source files, F3D files, project exports, support bundles, printer files).
- **Printer Card Cover Image Not Updating Between Prints** — The cover image on the printer card only refreshed on page reload. The `<img>` URL was always the same (`/printers/{id}/cover`) regardless of which print was active, so the browser served its cached image. Now appends the print name as a cache-busting query parameter so the browser fetches the new cover when a different print starts.
- **Telegram Bold Title Broken by Underscores in Message** ([#332](https://github.com/maziggy/bambuddy/issues/332)) — Telegram notifications showed literal `*Title*` asterisks instead of bold text when the message body contained underscores (e.g. job name `A1_plate_8`, error code `0300_0001`). The code was disabling Markdown parsing entirely when underscores were detected. Now escapes underscores in the body with `\_` so Markdown rendering stays enabled.
- **Queued Jobs Incorrectly Archived After Duplicate Execution Detection** ([#341](https://github.com/maziggy/bambuddy/issues/341)) — When the same file was added to the print queue multiple times, only the first job executed. All subsequent jobs were automatically skipped with "already printed X hours ago" because they shared the same archive reference, and a safety check incorrectly treated them as phantom reprints. The same issue also affected single queue items created from recently completed archives. Removed the overly broad 4-hour duplicate detection check — the crash recovery scenario it guarded against is already handled by the queue item status lifecycle.

### New Features
- **External Links: Open in New Tab** ([#338](https://github.com/maziggy/bambuddy/issues/338)) — External sidebar links can now optionally open in a new browser tab instead of an iframe. Sites behind reverse proxies (Traefik, nginx) that send `X-Frame-Options: SAMEORIGIN` or CSP `frame-ancestors` headers block iframe embedding, causing "refused to connect" errors. A new "Open in new tab" toggle in the add/edit link modal lets users choose per-link. Keyboard shortcuts (number keys) also respect the setting. Defaults to iframe (existing behavior) for backward compatibility.
- **Print Queue: Clear Plate Confirmation** — When a print finishes or fails and more items are queued, the printer card now shows a "Clear Plate & Start Next" button. The scheduler no longer auto-starts the next print while the printer is in FINISH or FAILED state — the user must confirm the build plate has been cleared first. This prevents prints from starting on a dirty plate. The button respects the `printers:control` permission and is available in all supported languages (en/de/ja).
- **Clear Plate State Persists Across Page Refresh** ([#410](https://github.com/maziggy/bambuddy/issues/410)) — After clicking "Clear Plate & Start Next", refreshing the page showed the Clear Plate button again because the frontend determined the state purely from the printer's FINISH/FAILED status. The `plate_cleared` flag is now included in the printer status API response, so the widget correctly shows the passive queue link instead of the Clear Plate button after acknowledgment — even after a page refresh.

### Improved
- **Skip Objects: Confirmation Dialog** ([#346](https://github.com/maziggy/bambuddy/issues/346)) — Added a warning confirmation modal before skipping an object during a print. Shows the object name and warns the action is irreversible. Prevents accidentally skipping the wrong object. Translated in all 4 locales (en, de, ja, it).
- **Additional Currency Options** ([#329](https://github.com/maziggy/bambuddy/issues/329), [#333](https://github.com/maziggy/bambuddy/issues/333)) — Added 17 additional currencies to the cost tracking dropdown: HKD, INR, KRW, SEK, NOK, DKK, PLN, BRL, TWD, SGD, NZD, MXN, CZK, THB, ZAR, RUB.
- **Move Email Settings Under Authentication Tab** — Renamed the settings "Users" tab to "Authentication" and moved the standalone "Global Email" tab into it as an "Email Authentication" sub-tab. Groups email/SMTP configuration with user management where it logically belongs. Legacy `?tab=email` URLs are handled automatically.
- **Inventory — Confirmation Modals for Delete & Archive** — The inventory page now uses the app's styled confirmation modal for both delete and archive actions. Previously, delete used the browser's native `confirm()` dialog and archive had no confirmation at all. Delete shows a danger-styled modal, archive shows a warning-styled modal. Translated in all 5 locales (en, de, fr, it, ja).
- **Default Color Catalog Expanded to 638 Colors Across 20 Brands** — The built-in filament color catalog has been expanded from 258 entries (6 brands) to 638 entries (20 brands). Added Overture, Sunlu, Creality, Elegoo, Jayo, Inland, Eryone, ColorFabb, Fillamentum, FormFutura, Fiberlogy, MatterHackers, Protopasta, 3DXTECH, and Sakata3D. eSUN expanded from 10 generic placeholder entries to 79 measured colors across 10 material lines (PLA+, Pro PLA+, PLA, PLA Silk, PLA Metal, PLA-ST, PETG, PETG-HS, ABS, ABS+). All hex codes sourced from FilamentColors.xyz measured swatches.
- **Settings — Built-in Inventory Feature Note** — Added a note in Settings > Filament > Built-in Inventory that third-party spools can be assigned to inventory spools for tracking.
- **Catalog Settings Cards Taller** — Spool Catalog and Color Catalog settings panels increased from 400px to 600px max height for better browsability with the expanded default catalogs.

## [0.1.9] - 2026-02-10

### New Features
- **Advanced Authentication via Email** ([#322](https://github.com/maziggy/bambuddy/pull/322)) — Optional SMTP-based email integration for streamlined user onboarding and self-service password management. Admins configure SMTP settings and create users with just a username and email — the system generates a secure random password and emails it directly to the new user. Admins can trigger one-click password resets from User Management. Users can reset their own forgotten password from the login screen without contacting an admin. Includes customizable email templates for welcome emails and password resets. Username and email login is case-insensitive. Can be enabled or disabled independently at any time without affecting existing accounts.
- **Configurable Slicer Preference** ([#313](https://github.com/maziggy/bambuddy/issues/313)) — New "Preferred Slicer" setting in General settings to choose between Bambu Studio and OrcaSlicer. Controls the protocol used by all "Open in Slicer" buttons across Archives, 3D Preview, and context menus. OrcaSlicer uses the `orcaslicer://open?file=` protocol. Default remains Bambu Studio for backward compatibility.
- **Local Profiles — OrcaSlicer Import** ([#310](https://github.com/maziggy/bambuddy/issues/310)) — Import slicer presets from OrcaSlicer without Bambu Cloud. Supports `.orca_filament`, `.bbscfg`, `.bbsflmt`, `.zip`, and `.json` exports. Resolves OrcaSlicer inheritance chains by fetching base Bambu profiles from GitHub (cached locally with 7-day TTL). Stores presets in the database with extracted core fields (material type, vendor, nozzle temps, pressure advance, compatible printers). New "Local Profiles" tab on the Profiles page with drag-and-drop import, 3-column layout (Filament/Process/Printer), search, and expandable preset details. Local filament presets appear in AMS slot configuration alongside cloud presets. Includes smart profile type detection (explicit type field, ZIP path hints, settings ID keys, content heuristics, and name-based patterns) and material/vendor extraction from preset names as fallback.
- **Hostname Support for Printers** ([#290](https://github.com/maziggy/bambuddy/issues/290)) — Printers can now be added using hostnames (e.g., `printer.local`, `my-printer.home.lan`) in addition to IPv4 addresses. Updated backend validation, frontend forms, and all locale labels.
- **Camera View Controls** ([#291](https://github.com/maziggy/bambuddy/issues/291)) — Added chamber light toggle and skip objects buttons to both embedded camera viewer and standalone camera page. Extracted skip objects modal into a reusable `SkipObjectsModal` component shared across PrintersPage and both camera views.
- **Per-Filament Spoolman Usage Tracking** ([#277](https://github.com/maziggy/bambuddy/pull/277)) — Accurate per-filament usage tracking for Spoolman integration with G-code parsing. Parses 3MF files at print start to build per-layer, per-filament extrusion maps. Reports accurate partial usage when prints fail or are cancelled based on actual layer progress. Tracking data stored in database to survive server restarts. Uses Spoolman's filament density for mm-to-grams conversion. Prefers `tray_uuid` over `tag_uid` for spool identification.
- **Disable AMS Weight Sync Setting** ([#277](https://github.com/maziggy/bambuddy/pull/277)) — New toggle to prevent AMS percentage-based weight estimates from overwriting Spoolman's granular usage-based calculations. Includes conditional "Report Partial Usage for Failed Prints" toggle.
- **Home Assistant Environment Variables** ([#283](https://github.com/maziggy/bambuddy/issues/283)) — Configure Home Assistant integration via `HA_URL` and `HA_TOKEN` environment variables for zero-configuration add-on deployments. Auto-enables when both variables are set. UI fields become read-only with lock icons when env-managed. Database values preserved as fallback.
- **Spoolman Fill Level for AMS Lite / External Spools** ([#293](https://github.com/maziggy/bambuddy/issues/293)) — AMS Lite (no weight sensor) always reported 0% fill level. Now uses Spoolman's remaining weight as a fallback when AMS reports 0%. External spools also show fill level from Spoolman data. Fill bars and hover cards indicate "(Spoolman)" when the data source is Spoolman rather than AMS.
- **Extended Support Bundle Diagnostics** — Support bundle now collects comprehensive diagnostic data for faster issue resolution: printer connectivity and firmware versions, integration status (Spoolman, MQTT, Home Assistant), network interfaces (subnets only), Python package versions, database health checks, Docker environment details, WebSocket connections, and log file info. All data properly anonymized — no IPs, names, or serials included. Privacy disclosure updated on System Info page.

### Improved
- **H2C Nozzle Rack — 6-Slot Display With Empty Placeholders** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — The nozzle rack card now always shows 6 rack positions (IDs 16–21), with filled slots showing diameter and empty slots showing placeholder dashes. L/R hotend nozzles (IDs 0, 1) are excluded from the rack card and shown in the dedicated L/R indicator instead.
- **H2 Series — L/R Nozzle Hover Card** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — New dual-nozzle hover card shows L and R nozzle details side by side (diameter, type, flow, status, wear, max temp, serial). Active nozzle highlighted in amber with Active/Idle status based on `active_extruder`, replacing the misleading "Docked" label.
- **H2 Series — Single-Nozzle Hover Card** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — H2D/H2S printers with a single nozzle now show extended nozzle details (wear, serial, max temp) on hover over the temperature card. Backend changed from H2C-only (>2 nozzles) to all H2 series (any nozzle_info present).
- **H2C Nozzle Rack — Translate Type Codes & Add Flow Info** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Raw nozzle type codes (e.g. "HS", "HH01") are now translated to human-readable names: material (Hardened Steel, Stainless Steel, Tungsten Carbide) and flow type (High Flow, Standard). New "Flow" row in the hover card. Translations added in all 4 locales (en, de, ja, it).
- **H2C Nozzle Rack — Show Filament Material in Hover Card** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle hover card now shows the loaded filament material type (e.g. "PLA", "PETG") alongside the color swatch, captured from MQTT nozzle info data.
- **H2C Nozzle Rack — Resolve Filament Names From Cloud & Local Profiles** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle rack hover card previously showed raw filament IDs like "GFU99" instead of human-readable names. Now resolves filament names with a 4-tier fallback: Bambu Cloud preset lookup → local slicer profiles → built-in filament name table (86 known Bambu filament codes) → raw ID fallback. The built-in table resolves names like "Bambu ASA", "Generic TPU", "Generic PLA" when the cloud API returns 400 for certain filament IDs. Also benefits AMS tray tooltips.
- **H2C Nozzle Rack Compact Layout** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Redesigned nozzle rack from a 2×3 grid to a compact single-row layout with bottom accent bars (green = mounted, gray = docked). Temperature cards are thinner, rack card is wider (flex-[2]), and all cards vertically centered.
- **Firmware Version Badge on Printer Card** ([#311](https://github.com/maziggy/bambuddy/issues/311)) — Printer cards now show a firmware version badge (when firmware checking is enabled). Green with checkmark when up to date, orange with download icon when an update is available. Clicking the badge opens a firmware info modal showing release notes (auto-expanded when up to date) or the existing update workflow. Badge and modal respect `firmware:read` and `firmware:update` permissions. Translations added in all 4 locales.
- **Auto-Detect Subnet for Printer Discovery** — Docker users no longer need to manually enter a subnet in the Add Printer dialog. Bambuddy auto-detects available network subnets and pre-selects the first one. When multiple subnets are available (e.g., eth0 + wlan0), a dropdown lets users choose. Falls back to manual text input if no subnets are detected.
- **Japanese Locale Complete Overhaul** — Restructured `ja.ts` from a divergent format (different key structure, 12 structural conflicts, 1,366 missing translations) to match the English/German locale structure exactly. Translated all 2,083 keys into Japanese, achieving full parity with EN/DE. Zero structural divergences, zero missing keys.

### Fixed
- **Nozzle Rack Hides 0% Wear** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — New nozzles with 0% wear showed no wear info in the hover card because the condition treated 0 the same as "not available." Now displays "Wear: 0%" correctly. The field is still hidden when the printer doesn't report wear data.
- **Nozzle Rack Shows L/R Hotend Nozzles in Rack** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — The nozzle rack card incorrectly included L/R hotend nozzles (IDs 0, 1) alongside the 6 rack slots. Now filters to IDs >= 2 (rack only) and always pads to 6 positions with empty placeholders.
- **H2C Firmware Update Downloads Wrong Firmware** ([#311](https://github.com/maziggy/bambuddy/issues/311)) — H2C printers were mapped to the H2D firmware API key (`h2d`), causing firmware checks to offer H2D firmware instead of H2C firmware. H2C has its own firmware track (01.01.x.x vs H2D's 01.02.x.x). Added separate `h2c` API key mapping. Also added missing H2C/H2S entries to printer model ID and 3MF model maps.
- **Sidebar Links Custom Icons Have Inverted Colors** ([#308](https://github.com/maziggy/bambuddy/issues/308)) — Custom uploaded icons in sidebar links had their colors inverted in dark mode due to a CSS `invert()` filter. The filter was intended for monochrome preset icons but was incorrectly applied to user-uploaded images (e.g., full-color logos). Removed the invert filter from custom icon rendering in the sidebar and the add/edit link modal.
- **Virtual Printer FTP Transfer Fails With Connection Reset** ([#58](https://github.com/maziggy/bambuddy/issues/58)) — Large 3MF uploads to the virtual printer intermittently failed with `[Errno 104] Connection reset by peer` while the small verify_job always succeeded. The `_handle_data_connection` callback returned immediately, allowing the asyncio server-handler task to complete while the data connection was still in active use. The passive port listener also stayed open during transfers, risking duplicate data connections. Fixed by keeping the callback alive until the transfer completes (`_transfer_done` event), closing the passive listener after accepting the connection, and rejecting duplicate data connections. Also added a 5-second drain timeout to MQTT status pushes to prevent blocking when the slicer is busy uploading.
- **Virtual Printer IP Override for Server Mode** ([#52](https://github.com/maziggy/bambuddy/issues/52)) — The `remote_interface_ip` setting (network interface override) was only used in proxy mode, but users with multiple network interfaces (LAN + Tailscale, Docker bridges) also needed it in server modes (immediate/review/print_queue). Auto-detected IP from `_get_local_ip()` followed the OS default route, causing wrong IP in TLS certificate SAN (handshake failures) and SSDP broadcasts (slicer can't discover printer). Now the interface override applies to all modes: included in certificate SAN, passed to SSDP server as advertise IP, and triggers service restart on change. UI dropdown shown for all modes when enabled (not just proxy).
- **Wrong Thumbnail When Reprinting Same Project** ([#314](https://github.com/maziggy/bambuddy/issues/314)) — Reprinting a project with the same name but a different bed layout showed the old thumbnail during printing. The cover image cache was keyed by `subtask_name` and never invalidated between prints, so a cache hit returned the stale first-print thumbnail. Now the cover cache is cleared on every print start.
- **Wrong Timelapse Attached to Archive** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — After a print, the archive could receive a timelapse from a previous print instead of the just-completed one. The auto-scan sorted MP4 files by mtime and grabbed the "most recent," but in LAN-only mode (no NTP) the printer's clock is wrong, making mtime unreliable. Replaced with a snapshot-diff approach: baseline existing files before waiting, then detect the new file that appears after encoding. Falls back to print-name matching if no new file is found after retries.
- **Timelapse Not Attached — Baseline Race Condition** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — Follow-up to the snapshot-diff timelapse fix: the baseline of existing MP4 files was captured at print completion time inside a background task, but fast-encoding printers could finish writing the timelapse before the baseline was taken, causing the new file to appear in the baseline and never be detected as "new." Moved baseline capture to print start time, when the timelapse file cannot possibly exist yet. Falls back to completion-time baseline if the app was restarted mid-print.
- **Calibration Prints Archived** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — Standalone calibration prints (flow, vibration, bed leveling) were being archived as regular prints. The calibration gcode (`/usr/etc/print/auto_cali_for_user.gcode`) and other internal printer files under `/usr/` are now detected and skipped during print start.
- **Camera Stop 401 When Auth Enabled** — Camera stop requests (`sendBeacon`) failed with 401 Unauthorized when authentication was enabled because `sendBeacon` cannot send auth headers. Replaced with `fetch` + `keepalive: true` which supports Authorization headers while remaining reliable during page unload.
- **Spoolman Creates Duplicate Spools on Startup** ([#295](https://github.com/maziggy/bambuddy/pull/295)) — Each AMS tray independently fetched all spools from Spoolman, causing redundant API calls and duplicate spool creation with large databases (300+ spools). Now fetches spools once and reuses cached data across all tray operations. Added retry logic (3 attempts, 500ms delay) with connection recreation for transient network errors.
- **Filament Usage Charts Inflated by Quantity Multiplier** ([#229](https://github.com/maziggy/bambuddy/issues/229)) — Daily, weekly, and filament-type charts were multiplying `filament_used_grams` by print quantity, even though the value already represents the total for the entire job. A 26-object print using 126g was counted as 3,276g. Removed the erroneous multiplier from three aggregations in `FilamentTrends.tsx`.
- **Energy Cost Shows 0.00 in "Total Consumption" Mode** ([#284](https://github.com/maziggy/bambuddy/issues/284)) — Statistics Quick Stats showed 0.00 energy cost when Energy Display Mode was set to "Total Consumption" with Home Assistant smart plugs. The `homeassistant_service` was not configured with HA URL/token before querying plug energy data, causing it to silently return nothing.
- **H2D Pro Prints Fail at ~75% With Extrusion Motor Overload** ([#245](https://github.com/maziggy/bambuddy/issues/245)) — H2D Pro firmware interprets `use_ams: 1` (integer) as a nozzle index, routing filament to the deputy nozzle instead of the main nozzle. Bambu Studio sends `use_ams: true` (boolean) while using integers for other fields. Fixed by keeping `use_ams` as boolean for all printers including H2D series.
- **GitHub Backup Description Misleading** — The "App Settings" backup card said "excludes sensitive data" but the complete database is pushed. Updated description to "complete database."
- **Support Bundle Shows 0 AMS Units** — The support info always reported `ams_unit_count: 0` because it expected `raw_data["ams"]` to be a nested dict (`{"ams": [...]}`) but the MQTT handler stores it as a flat list. Now handles both formats.
- **Firmware Badge Shown for Models Without API Data** ([#311](https://github.com/maziggy/bambuddy/issues/311)) — Printers whose model has no firmware data in Bambu Lab's API (e.g. H2C on public beta firmware) showed a misleading green "up to date" badge. The badge is now hidden when the API returns no `latest_version`, since there is nothing to compare against.
- **AMS-HT Mapping Fails for Left Nozzle on H2D Pro** ([#318](https://github.com/maziggy/bambuddy/issues/318)) — Printing with the left nozzle on dual-nozzle printers (H2D/H2D Pro) using AMS-HT failed with "Failed to get AMS mapping table." The global tray ID for AMS-HT units (ams_id >= 128) was calculated as `ams_id * 4 + tray_id` (= 512), but AMS-HT uses the raw `ams_id` (128) since it has a single tray. The backend then misidentified 512 as an external spool. Fixed in frontend tray ID calculation, backend `ams_mapping2` builder, print scheduler, and Spoolman tracking.
- **H2D Pro L/R Nozzle Hover Card Swapped** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — The dual-nozzle hover card had left and right nozzles swapped: nozzle_rack id 0 (extruder 0 = right) was shown as left and vice versa. Serial number and max temp now correctly appear only on the right (removable) nozzle column.
- **H2C Printer Card Shows H2D Image** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — The H2C printer card displayed the H2D printer image because no dedicated H2C image existed in the frontend. Added H2C image and updated `getPrinterImage()` to return it for H2C models.
- **H2C Nozzle Rack Shows Wrong Empty Slot and Missing Filament Colors** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Empty rack slots always appeared at position 6 instead of their actual position because nozzles were mapped by array index instead of by ID. Fixed by mapping each nozzle to its correct rack position (`id - 16`). Filament colors and materials were missing because the H2C uses different MQTT field names (`color_m`, `fila_id`, `sn`, `tm`) than the H2D (`filament_colour`, `filament_id`, `serial_number`, `max_temp`). Added fallback field name resolution. Also fixed nozzle rack layout breaking on medium card size by allowing the temperature row to wrap.

### Documentation
- **Advanced Auth via Email** — Updated README, website features page, and wiki authentication guide with SMTP setup, self-service password reset, admin password reset, email templates, and advanced auth overview.
- **Supported Printers Updated** — Updated README, website, and wiki to list all 12 supported Bambu Lab printer models: X1, X1C, X1E, P1P, P1S, P2S, A1, A1 Mini, H2D, H2D Pro, H2C, H2S. Removed outdated "Testers Needed" messaging and Tested/Needs Testing distinctions — all models are now uniformly listed as supported. Added H2C printer image to website. Added H2D Pro, H2C columns to wiki feature comparison tables and new P2 Series section.
- **CONTRIBUTING.md: i18n & Authentication Guides** — Added Internationalization (i18n) section with locale file conventions, code examples, and parity rules. Added Authentication & Permissions section covering the opt-in auth pattern, permission conventions, and default group structure.
- **Proxy Mode Security Warning** — Added FTP data channel security warning to wiki, README, and website. Bambu Studio does not encrypt the FTP data channel despite negotiating PROT P; MQTT and FTP control channels are fully TLS-encrypted. VPN (Tailscale/WireGuard) recommended for full data encryption.
- **Docker Proxy Mode Ports** — Documented FTP passive data ports 50000-50100 required for proxy mode in Docker bridge mode. Updated port mappings in wiki virtual-printer and docker guides.
- **SSDP Discovery Limitations** — Added table showing when SSDP discovery works (same LAN, dual-homed, Docker host mode) vs when manual IP entry is required (VPN, Docker bridge, port forwarding). Updated wiki, README, and website.
- **Firewall Rules Updated** — Added port 50000-50100/tcp to all UFW, firewalld, and iptables examples for proxy mode FTP passive data.

### Testing
- **Mock FTPS Server & Comprehensive FTP Test Suite** — Added 67 automated test cases against a real implicit FTPS mock server, covering every known FTP failure mode from 0.1.8+:
  - Mock server (`mock_ftp_server.py`) implements implicit TLS, custom AVBL command, and per-command failure injection
  - Connection tests: auth, SSL modes (prot_p/prot_c), timeout, cache, disconnect edge cases
  - Upload tests: chunked transfer via `transfercmd()`, progress callbacks, 553/550/552 error handling
  - Download tests: bytes, to-file, 0-byte regression, large files, missing file cleanup
  - Model-specific tests: X1C session reuse, A1/A1 Mini prot_c fallback, P1S, unknown model defaults
  - Async wrapper tests: upload/download/list/delete with A1 fallback and multi-path download
  - Failure injection tests: regressions for `error_perm` hierarchy, `diagnose_storage` CWD propagation, injection count decrement
  - Added `pyOpenSSL` to `requirements-dev.txt` for Docker test image compatibility
- **Nozzle Rack Tests** — Backend: 7 tests for MQTT nozzle_info parsing (H2C 8-entry, H2D 2-entry, H2S single, empty, sorting, field mapping, nozzle state updates). Frontend: 3 tests for rack card rendering (H2C shows 6 slots, empty placeholders, hidden when no rack IDs).

## [0.1.8.1] - 2026-02-07

### Fixed
- **FTP Upload Broken on All Printer Models** — Fixed critical bug where all FTP uploads failed with "550 Failed to change directory":
  - `diagnose_storage()` was running before every upload, and its CWD failures (`ftplib.error_perm`) were not caught because `error_perm` is not a subclass of `error_reply`
  - Removed `diagnose_storage()` from the upload hot path
  - Changed all FTP exception handlers from `except (OSError, ftplib.error_reply)` to `except (OSError, ftplib.Error)` to catch all FTP error types
- **HTTP 500 on Reprint and Print Endpoints** — Fixed 500 errors on `/api/v1/archives/{id}/reprint` and `/api/v1/library/files/{id}/print` caused by the FTP failure above
- **Exception Handling Reverted** — Reverted overly-narrow exception handling introduced in 0.1.8 that could cause uncaught errors in archive parsing, HTTP clients, 3MF/ZIP processing, Home Assistant, and firmware checks
- **HTTP 500 on Printer Cover Image** — Fixed 500 error on `/api/v1/printers/{id}/cover` when FTP download returned 0 bytes but reported success; now retries and falls back to 404
- **4-Segment Version Support** — Version parser now supports patch releases like `0.1.8.1` for hotfixes without incrementing the minor version

## [0.1.8] - 2026-02-06

### Security
- **XML External Entity (XXE) Prevention**:
  - Replaced `xml.etree.ElementTree` with `defusedxml` across all 3MF parsing code
  - Prevents XXE attacks through malicious 3MF files
  - Detected by Bandit B314 security scanner
- **Path Injection Vulnerabilities Fixed**:
  - Added path traversal validation to project attachment endpoints
  - Strengthened filename sanitization in timelapse processing
  - Prevents directory traversal attacks via `../` sequences
  - Detected by CodeQL security scanner
- **Security Scanning in CI/CD**:
  - Added Bandit (Python security analyzer) with SARIF upload to GitHub Security
  - Added Trivy (container/IaC scanner) for Docker image and Dockerfile analysis
  - Added pip-audit and npm-audit for dependency vulnerability scanning
  - Automatic GitHub issue creation for detected vulnerabilities
  - Security scan results visible in GitHub Security tab
- **CodeQL Zero-Finding Baseline**:
  - Reduced CodeQL findings from 591 to 0 across Python, JavaScript, and GitHub Actions
  - Created custom query suites (`.codeql/python-bambuddy.qls`, `.codeql/javascript-bambuddy.qls`) with documented accepted-risk exclusions
  - All exclusions reviewed and justified (log injection parameterized, cyclic imports from SQLAlchemy ORM, intentional 0.0.0.0 binds, etc.)
- **Log Injection Prevention**:
  - Converted ~700 f-string log calls to parameterized `%s` style across all backend files
  - Prevents log injection via newlines or fake log entries in user-controlled data
- **Exception Handling Hardened**:
  - Narrowed ~265 bare `except Exception` blocks to specific types (`OSError`, `KeyError`, `ValueError`, `zipfile.BadZipFile`, `sqlalchemy.exc.OperationalError`, etc.)
- **Stack Trace Exposure Fixed**:
  - Replaced `str(e)` with generic error messages in HTTP responses (`updates.py`)
  - Detailed errors still logged server-side for debugging
- **SSRF Mitigations Added**:
  - Home Assistant integration: URL scheme/hostname validation, metadata-service blocking (`homeassistant.py`)
  - Tasmota integration: IP validation blocking loopback and link-local addresses (`tasmota.py`)
- **Hashlib Security Annotations**:
  - Added `usedforsecurity=False` to non-security hash calls (MD5 for AMS fingerprinting, SHA1 for git blob format)
- **Unused Code Removal**:
  - Removed ~30 redundant function-level imports, unused variables, dead code, and trivial conditions flagged by CodeQL
- **Local Security Scanner Improvements**:
  - `test_security.sh` uses `--threads=0` for all CodeQL commands (auto-detects CPU cores)
  - Added `.trivyignore` to suppress accepted Dockerfile USER directive finding

### Enhancements
- **Per-Filament Spoolman Usage Tracking** (PR #277):
  - Reports exact filament consumption per spool to Spoolman after each print
  - Parses G-code from 3MF files for layer-by-layer extrusion data (multi-material support)
  - New setting: "Disable AMS Estimated Weight Sync" to prefer Spoolman usage tracking over AMS weight estimates
  - New setting: "Report Partial Usage for Failed Prints" estimates filament used up to the failure point based on layer progress
  - Persists tracking data in SQLite for reliability across restarts
  - Extracted Spoolman tracking into dedicated service module with DRY helpers
- **3D Model Viewer Improvements** (PR #262):
  - Added plate selector for multi-plate 3MF files with thumbnail previews
  - Object count display shows number of objects per plate and total
  - Fullscreen toggle for immersive model viewing
  - Resizable split view between plate selector and 3D viewer in fullscreen mode
  - Pagination support for files with many plates (e.g., 50+ plates)
  - Added i18n translations for all model viewer strings (English, German, Japanese)
- **Virtual Printer Proxy Mode Improvements**:
  - SSDP proxy for cross-network setups: select slicer network interface for automatic printer discovery via SSDP relay
  - FTP proxy now listens on privileged port 990 (matching Bambu Studio expectations) instead of 9990
  - For systemd: requires `AmbientCapabilities=CAP_NET_BIND_SERVICE` capability
  - Automatic directory permission checking at startup with clear error messages for Docker/bare metal
  - Updated translations for proxy mode steps in English, German, and Japanese

### Fixed
- **Authentication Required Error After Initial Setup** (Issue #257):
  - Fixed "Authentication required" error when using printer controls after fresh install with auth enabled
  - Token clearing on 401 responses is now more selective - only clears on invalid token messages
  - Generic "Authentication required" errors (which may be timing issues) no longer clear the token
  - Also fixed smart plug discovery scan endpoints missing auth headers
- **Filament Hover Card Overlapping Navigation Bar** (Issue #259):
  - Fixed filament info popup being partially covered by the navigation bar
  - Hover card positioning now accounts for the fixed 56px header
  - Cards near the top of the page now correctly flip to show below the slot
- **Filament Statistics Incorrectly Multiplied by Quantity** (Issue #229):
  - Fixed filament totals being inflated by incorrectly multiplying by quantity
  - The `filament_used_grams` field already contains the total for the entire print job
  - Removed incorrect `* quantity` multiplication from archive stats, Prometheus metrics, and FilamentTrends chart
  - Example: A print with 26 objects using 126g was incorrectly shown as 3,276g
- **Print Queue Status Does Not Match Printer Status** (Issue #249):
  - Queue now shows "Paused" when the printer is paused instead of "Printing"
  - Fetches real-time printer state for actively printing queue items
  - Added translations for paused status in English, German, and Japanese
- **Queue Scheduled Time Displayed in Wrong Timezone** (Issue #233):
  - Fixed scheduled time being displayed in UTC instead of local timezone when editing queue items
  - The datetime picker now correctly shows and saves times in the user's local timezone
- **Mobile Layout Issues on Archives and Statistics Pages** (Issue #255):
  - Fixed header buttons overflowing outside the screen on iPhone/mobile devices
  - Headers now stack vertically on small screens with proper wrapping
  - Applied consistent responsive pattern from PrintersPage
- **AMS Auto-Matching Selects Wrong Slot** (Issue #245):
  - Fixed AMS slot mapping when multiple trays have the same `tray_info_idx` (filament type identifier)
  - `tray_info_idx` (e.g., "GFA00" for generic PLA) identifies filament TYPE, not unique spools
  - When multiple trays match the same type, color is now used as a tiebreaker
  - Previously used `find()` which always returned the first match regardless of color
  - Fixed in both backend (print_scheduler.py) and frontend (useFilamentMapping.ts)
  - Resolves wrong tray selection (e.g., A4 instead of B1) when multiple AMS units have same filament type
- **A1/A1 Mini FTP Upload Failures** (Issue #271):
  - Fixed FTP uploads hanging/timing out on A1 and A1 Mini printers
  - Replaced `storbinary()` with manual chunked transfer using `transfercmd()`
  - A1's FTP server has issues with Python's `storbinary()` waiting for completion response
  - Uses 1MB chunks with explicit 120s socket timeout for reliable transfers
  - Works for all printer models (X1C, P1S, P1P, A1, A1 Mini)
- **P1S/P1P FTP Upload Failures**:
  - Fixed FTP uploads failing with EOFError on P1S and P1P printers
  - These printers use vsFTPd which requires SSL session reuse on data channel
  - Removed P1S/P1P from skip-session-reuse list (they were incorrectly added)
- **FTP Auto-Detection for A1 Printers**:
  - Automatically detects working FTP mode (prot_p vs prot_c) for A1/A1 Mini
  - Tries encrypted data channel first, falls back to clear if needed
  - Caches working mode per printer IP to avoid repeated detection
- **Safari Camera Stream Failing**:
  - Fixed camera streams not loading in Safari due to Service Worker error
  - Safari has stricter Service Worker scope requirements
- **Queue Print Time for Multi-Plate Files** (PR #274):
  - Fixed print time showing total for all plates instead of selected plate
  - Now extracts per-plate print time from 3MF slice_info.config
  - Contributed by MisterBeardy
- **Docker Permissions**:
  - Added user directive to docker-compose.yml using PUID/PGID environment variables
  - Allows container to run as host user, fixing permission issues with bind-mounted volumes
  - Usage: `PUID=$(id -u) PGID=$(id -g) docker compose up -d`

### Added
- **Windows Portable Launcher** (contributed by nmori):
  - New `start_bambuddy.bat` for Windows users - double-click to run, no installation required
  - Automatically downloads Python 3.13 and Node.js 22 on first run (portable, no system changes)
  - Everything stored in `.portable\` folder for easy cleanup
  - Commands: `start_bambuddy.bat` (launch), `start_bambuddy.bat update` (update deps), `start_bambuddy.bat reset` (clean start)
  - Custom port via `set PORT=9000 & start_bambuddy.bat`
  - Verifies all downloads with SHA256 checksums for security
  - Supports both x64 and ARM64 Windows systems

## [0.1.7] - 2026-02-03

### Security
- **Critical: Missing API Endpoint Authentication** (CVE-2026-25505, CVSS 9.8):
  - Added authentication to 200+ API endpoints that were previously unprotected
  - All route files now use `RequirePermissionIfAuthEnabled()` for permission checks
  - Protected endpoints: archives, projects, settings, API keys, groups, cloud, notifications, maintenance, filaments, external links, smart plugs, discovery, firmware, camera, k-profiles, AMS history, pending uploads, updates, spoolman, system, print queue, printers
  - Image-serving endpoints (thumbnails, timelapse, photos, camera streams) remain public as they require knowing the resource ID and are loaded via `<img>` tags which cannot send Authorization headers
  - Backend integration tests added to verify endpoint authentication enforcement

### Enhancements
- **TOTP Authenticator Support for Bambu Cloud** (Issue #182):
  - Added support for TOTP-based two-factor authentication when connecting to Bambu Cloud
  - Accounts with authenticator apps (Google Authenticator, Authy, etc.) now work correctly
  - Proper detection of verification type: email code vs TOTP code
  - Uses browser-like headers to bypass Cloudflare protection on TFA endpoint
  - Frontend shows appropriate message for each verification type
  - Added translations for TOTP UI in English, German, and Japanese
- **Spoolman: Open in Spoolman Button** (Issue #210):
  - FilamentHoverCard now shows "Open in Spoolman" button when spool is already linked in Spoolman
  - Button links directly to the spool's page in Spoolman for quick editing
  - "Link to Spoolman" button now only shows when spool is not yet linked
  - Link button correctly disabled when no unlinked spools are available in Spoolman
  - Toast notification shown on successful/failed spool linking
  - Added `/api/v1/spoolman/spools/linked` endpoint returning map of linked spool tags to IDs
- **Complete German Translations**:
  - All UI strings now fully translated to German (1800+ translation keys)
  - Pages translated: Settings, Archives, File Manager, Queue, Printers, Profiles, Projects, Stats, Maintenance, Camera, Groups, Users, Login, Setup, Stream Overlay
  - Components translated: ConfirmModal, LinkSpoolModal, FilamentHoverCard, Layout
  - Added locale parity test to ensure English and German stay in sync
- **Virtual Printer Proxy Mode**:
  - New "Proxy" mode allows remote printing over any network by relaying slicer traffic to a real printer
  - Configure a target printer and Bambuddy acts as a TLS proxy between your slicer and the printer
  - Supports both FTP (port 990) and MQTT (port 8883) protocols with full TLS encryption
  - Slicer connects to Bambuddy using the real printer's access code
  - Real-time status display showing active FTP/MQTT connections
  - Target printer selector with validation (must be configured in Bambuddy)
  - Proxy mode bypasses the access code requirement (uses the real printer's credentials)
  - Full i18n support for all proxy mode UI strings (English, German, Japanese)

### Fixed
- **Cannot Link Multiple HA Entities to Same Printer** (Issue #214):
  - Fixed Home Assistant entities being limited to one per printer
  - Both frontend and backend were blocking printers that already had any smart plug linked
  - Now only Tasmota plugs are limited to one per printer (physical device constraint)
  - Multiple HA entities (switches, scripts, lights, etc.) can be linked to the same printer
  - Restored "Show on Printer Card" toggle for HA entities to control visibility on printer cards
  - Fixed printer card only showing `script.*` entities; now shows all HA entities with toggle enabled
  - HA entities now default to auto_on=False and auto_off=False (appropriate for automations)
  - Printer cards now update immediately when HA entities are added/modified/deleted
- **Monthly Comparison Calculation Off** (Issue #229):
  - Fixed filament statistics not accounting for quantity multiplier
  - Monthly comparison chart now correctly multiplies `filament_used_grams` by `quantity`
  - Daily and weekly charts also now account for quantity
  - Filament type breakdown includes quantity in calculations
  - Backend stats endpoint (`/archives/stats`) and Prometheus metrics also fixed
  - Prints count now shows total items (sum of quantities) instead of archive count
- **Authentication Required for Downloads** (Issue #231):
  - Fixed support bundle download returning 401 Unauthorized when auth is enabled
  - Fixed archive export (CSV/XLSX) failing with authentication enabled
  - Fixed statistics export failing with authentication enabled
  - Fixed printer file ZIP download failing with authentication enabled
  - Root cause: These endpoints used raw `fetch()` without Authorization header
- **Queue Schedule Date Picker Ignores User Format Settings** (Issue #233):
  - Replaced native datetime picker with custom date/time inputs respecting user settings
  - Date input shows in user's format (DD/MM/YYYY for EU, MM/DD/YYYY for US, YYYY-MM-DD for ISO)
  - Time input shows in user's format (24H or 12H with AM/PM)
  - Calendar button opens native picker for convenience; selection is formatted to user's preference
  - Placeholder text shows expected format (e.g., "DD/MM/YYYY" or "HH:MM AM/PM")
  - Added date utilities: `formatDateInput`, `parseDateInput`, `getDatePlaceholder`
  - Added time utilities: `formatTimeInput`, `parseTimeInput`, `getTimePlaceholder`
- **500 Error on Archive Detail Page**:
  - Fixed internal server error when viewing individual archive details
  - Root cause: `project` relationship not eagerly loaded in `get_archive()` service method
  - Async SQLAlchemy requires explicit eager loading; lazy loading is not supported

## [0.1.6.2] - 2026-02-02

> **Security Release**: This release addresses critical security vulnerabilities. Users running authentication-enabled instances should upgrade immediately.

### Security
- **Critical: Hardcoded JWT Secret Key** (GHSA-gc24-px2r-5qmf, CWE-321) - Fixed hardcoded JWT secret key that could allow attackers to forge authentication tokens:
  - JWT secret now loaded from `JWT_SECRET_KEY` environment variable (recommended for production)
  - Falls back to auto-generated `.jwt_secret` file in data directory with secure permissions (0600)
  - Generates cryptographically secure 64-byte random secret if neither exists
  - **Action Required**: Existing users will need to re-login after upgrading
- **Critical: Missing API Authentication** (GHSA-gc24-px2r-5qmf, CWE-306) - Fixed 77+ API endpoints that lacked authentication checks:
  - Added HTTP middleware enforcing authentication on ALL `/api/` routes when auth is enabled
  - Only essential public endpoints are exempt (login, auth status, version check, WebSocket)
  - All other API calls now require valid JWT token or API key

### Enhancements
- **Location Filter for Queue** (Issue #220):
  - Filter queue jobs by printer location in the Queue page
  - "Any {Model}" queue assignments can now specify a target location (e.g., "Any X1C in Workshop")
  - Location filter dropdown shows all unique locations from printers and queue items
  - Location is saved with queue items and displayed in the queue list
- **Ownership-Based Permissions** (Issue #205):
  - Users can now only update/delete their own items unless they have elevated permissions
  - Update/delete permissions split into `*_own` and `*_all` variants:
    - `queue:update_own` / `queue:update_all`
    - `queue:delete_own` / `queue:delete_all`
    - `archives:update_own` / `archives:update_all`
    - `archives:delete_own` / `archives:delete_all`
    - `archives:reprint_own` / `archives:reprint_all`
    - `library:update_own` / `library:update_all`
    - `library:delete_own` / `library:delete_all`
  - Administrators group gets `*_all` permissions (can modify any items)
  - Operators group gets `*_own` permissions (can only modify their own items)
  - Ownerless items (legacy data without creator) require `*_all` permission
  - Bulk operations skip items user doesn't have permission to modify
  - User deletion now offers choice: delete user's items or keep them (become ownerless)
  - Backend enforces permissions on all API endpoints (not just frontend UI)
  - Automatic migration upgrades existing groups to new permission model
- **User Tracking for Archives, Library & Queue** (Issue #206):
  - Track and display who uploaded each archive file
  - Track and display who uploaded each library file (File Manager)
  - Track and display who added each print job to the queue
  - Shows username on archive cards, library files, queue items, and printer cards (while printing)
  - Works when authentication is enabled; gracefully hidden when auth is disabled
  - Database migration adds `created_by_id` columns to `print_archives`, `library_files`, and `print_queue` tables
- **Separate AMS RFID Permission** (Issue #204):
  - Added new `printers:ams_rfid` permission for re-reading AMS RFID tags
  - Allows granting RFID re-read access without full printer control permissions
  - Operators group includes this permission by default
  - Available in Settings > Users > Group Editor as a toggleable permission
- **Schedule Button on Archive Cards** (Issue #208):
  - Added "Schedule" button next to "Reprint" on archive cards for quick access to print scheduling
  - Previously only available in the context menu (right-click)
  - Respects `queue:create` permission for users with restricted access
- **Streaming Overlay Improvements** (Issue #164):
  - **Configurable FPS**: Add `?fps=30` parameter to control camera frame rate (1-30, default 15)
  - **Status-only mode**: Add `?camera=false` parameter to hide camera and show only status overlay on black background
  - Increased default camera FPS from 10 to 15 for smoother video across all camera views
- **Simplified Backup/Restore System**:
  - Complete backup now creates a single ZIP file containing the entire database and all data directories
  - Includes: database, archives, library files, thumbnails, timelapses, icons, projects, and plate calibration data
  - Portable backups: works across different installations and data directories
  - Faster backup/restore: direct file copy instead of JSON export/import
  - Progress indicator and navigation blocking during backup/restore operations
  - Removed ~2000 lines of legacy JSON-based backup/restore code

### Fixes
- **File Manager permissions not enforced** (Issue #224) - Fixed backend not checking `library:read` permission for File Manager endpoints:
  - Added `library:read` permission check to all list/view endpoints (files, folders, stats)
  - Added `library:upload` permission check to upload and folder creation endpoints
  - Added `queue:create` permission check to add-to-queue endpoint
  - Added `printers:control` permission check to direct print endpoint
  - Added ownership-based permission checks to file move operation
  - Users without `library:read` permission can no longer view files in the File Manager
  - Users can now only delete/update their own files unless they have `*_all` permissions
- **JWT secret key not persistent across restarts** - Fixed JWT secret key generation to properly use data directory, ensuring tokens remain valid across container restarts
- **Images/thumbnails returning 401 when auth enabled** - Fixed auth middleware to allow public access to image/media endpoints (thumbnails, photos, QR codes, timelapses, camera streams) since browser elements like `<img>` don't send Authorization headers
- **Library thumbnails missing after restore** - Fixed library files using absolute paths that break after restore on different systems:
  - Library now stores relative paths in database for portability
  - Automatic migration converts existing absolute paths to relative on startup
  - Thumbnails and files now display correctly after restoring backups
- **File uploads failing with authentication enabled** - Fixed all file upload functions (archives, photos, timelapses, library files, etc.) not sending authentication headers when auth is enabled
- **External spool AMS mapping causing "Failed to get AMS mapping table"** (Issue #213) - Fixed external spool `ams_mapping2` slot_id handling that caused AMS mapping failures
- **Filename matching for files with spaces** (Issue #218) - Fixed file detection when filenames contain spaces
- **P2S FTP upload failure** (Issue #218) - Fixed FTP uploads to P2S printers by passing `skip_session_reuse` to ImplicitFTP_TLS
- **Printer deletion freeze** (Issue #214) - Fixed UI freeze when deleting printers, and now allows multiple smart plugs per printer
- **Stack trace exposure in error responses** (CodeQL Alert #68) - Fixed stack traces being exposed in API error responses in archives.py
- **Printer serial numbers exposed in support bundle** (Issue #216) - Sanitized printer serial numbers in support bundle logs for privacy
- **Missing sliced_for_model migration** (Issue #211) - Fixed database migration for `sliced_for_model` column that was missing in some upgrade paths

## [0.1.6-final] - 2026-01-31

### New Features
- **Group-Based Permissions** - Granular access control with user groups:
  - Create custom groups with specific permissions (50+ granular permissions)
  - Default system groups: Administrators (full access), Operators (control printers), Viewers (read-only)
  - Users can belong to multiple groups with additive permissions
  - Permission-based UI: buttons/features disabled when user lacks permission
  - Groups management page in Settings → Users → Groups tab
  - Change password: users can change their own password from sidebar
  - Included in backup/restore
- **STL Thumbnail Generation** - Auto-generate preview thumbnails for STL files (Issue #156):
  - Checkbox option when uploading STL files to generate thumbnails automatically
  - Batch generate thumbnails for existing STL files via "Generate Thumbnails" button
  - Individual file thumbnail generation via context menu (three-dot menu)
  - Works with ZIP extraction (generates thumbnails for all STL files in archive)
  - Uses trimesh and matplotlib for 3D rendering with Bambu green color theme
  - Thumbnails auto-refresh in UI after generation
  - Graceful handling of complex/invalid STL files
- **Streaming Overlay for OBS** - Embeddable overlay page for live streaming with camera and print status (Issue #164):
  - All-in-one page at `/overlay/:printerId` combining camera feed with status overlay
  - Real-time print progress, ETA, layer count, and filename display
  - Bambuddy logo branding (links to GitHub)
  - Customizable via query parameters: `?size=small|medium|large` and `?show=progress,layers,eta,filename,status,printer`
  - No authentication required - designed for OBS browser source embedding
  - Gradient overlay at bottom for readable text over camera feed
  - Auto-reconnect on camera stream errors
- **MQTT Smart Plug Support** - Add smart plugs that subscribe to MQTT topics for energy monitoring (Issue #173):
  - New "MQTT" plug type alongside Tasmota and Home Assistant
  - Subscribe to any MQTT topic (Zigbee2MQTT, Shelly, Tasmota discovery, etc.)
  - **Separate topics per data type**: Configure different MQTT topics for power, energy, and state
  - Configurable JSON paths for data extraction (e.g., `power_l1`, `data.power`)
  - **Separate multipliers**: Individual multiplier for power and energy (e.g., mW→W, Wh→kWh)
  - **Custom ON value**: Configure what value means "ON" for state (e.g., "ON", "true", "1")
  - Monitor-only: displays power/energy data without control capabilities
  - Reuses existing MQTT broker settings from Settings → Network
  - Energy data included in statistics and per-print tracking
  - Full backup/restore support for MQTT plug configurations
- **Disable Printer Firmware Checks** - New toggle in Settings → General → Updates to disable printer firmware update checks:
  - Prevents Bambuddy from checking Bambu Lab servers for firmware updates
  - Useful for users who prefer to manage firmware manually or have network restrictions
- **Archive Plate Browsing** - Browse plate thumbnails directly in archive cards (Issue #166):
  - Hover over archive card to reveal plate navigation for multi-plate files
  - Left/right arrows to cycle through plate thumbnails
  - Dot indicators show current plate (clickable to jump to specific plate)
  - Lazy-loads plate data only when user hovers
- **GitHub Profile Backup** - Automatically backup your Cloud profiles, K-profiles and settings to a GitHub repository:
  - Configure GitHub repository URL and Personal Access Token
  - Schedule backups hourly, daily, or weekly
  - Manual on-demand backup trigger
  - Backs up K-profiles (per-printer), cloud profiles, and app settings
  - Skip unchanged commits (only creates commit when data changes)
  - Real-time progress tracking during backup
  - Backup history log with status and commit links
  - Requires Bambu Cloud login for full profile access
  - New Settings → Backup & Restore tab (local backup/restore moved here)
  - Included in local backup/restore (except PAT for security)
- **Plate Not Empty Notification** - Dedicated notification category for build plate detection:
  - New toggle in notification provider settings (enabled by default)
  - Sends immediately (bypasses quiet hours and digest mode)
  - Separate from general printer errors for granular control
- **USB Camera Support** - Connect USB webcams directly to your Bambuddy host:
  - New "USB Camera (V4L2)" option in external camera settings
  - Auto-detection of available USB cameras via V4L2
  - API endpoint to list connected USB cameras (`GET /api/v1/printers/usb-cameras`)
  - Works with any V4L2-compatible camera on Linux
  - Uses ffmpeg for frame capture and streaming
- **Build Plate Empty Detection** - Automatically detect if objects are on the build plate before printing:
  - Per-printer toggle to enable/disable plate detection
  - Multi-reference calibration: Store up to 5 reference images of empty plates (different plate types)
  - Automatic print pause when objects detected on plate at print start
  - Push notification and WebSocket alert when print is paused due to plate detection
  - ROI (Region of Interest) calibration UI with sliders to focus detection on build plate area
  - Reference management: View thumbnails, add labels, delete references
  - Works with both built-in and external cameras
  - Uses buffered camera frames when stream is active (no blocking)
  - Split button UI: Main button toggles detection on/off, chevron opens calibration modal
  - Green visual indicator when plate detection is enabled
  - Included in backup/restore
- **Project Import/Export** - Export and import projects with full file support (Issue #152):
  - Export single project as ZIP (includes project settings, BOM, and all files from linked library folders)
  - Export all projects as JSON for metadata-only backup
  - Import from ZIP (with files) or JSON (metadata only)
  - Linked folders and files are automatically created on import
  - Useful for sharing complete project bundles or migrating between instances
- **BOM Item Editing** - Bill of Materials items are now fully editable:
  - Edit name, quantity, price, URL, and remarks after creation
  - Pencil icon on each BOM item to enter edit mode
- **Prometheus Metrics Endpoint** - Export printer telemetry for external monitoring systems (Issue #161):
  - Enable via Settings → Network → Prometheus Metrics
  - Endpoint: `GET /api/v1/metrics` (Prometheus text format)
  - Optional bearer token authentication for security
  - Printer metrics: connection status, state, temperatures (bed, nozzle, chamber), fans, WiFi signal
  - Print metrics: progress, remaining time, layer count
  - Statistics: total prints by status, filament used, print time
  - Queue metrics: pending and active jobs
  - System metrics: connected printers count
  - Labels include printer_id, printer_name, serial for filtering
  - Ready for Grafana dashboards
- **External Link for Archives** - Add custom external links to archives for non-MakerWorld sources (Issue #151):
  - Link archives to Printables, Thingiverse, or any other URL
  - Globe button opens external link when set, falls back to auto-detected MakerWorld URL
  - Edit via archive edit modal
  - Included in backup/restore
- **External Network Camera Support** - Add external cameras (MJPEG, RTSP, HTTP snapshot) to replace built-in printer cameras (Issue #143):
  - Configure per-printer external camera URL and type in Settings → Camera
  - Live streaming uses external camera when enabled
  - Finish photo capture uses external camera
  - Layer-based timelapse: captures frame on each layer change, stitches to MP4 on print completion
  - Test connection button to verify camera accessibility
- **Recalculate Costs Button** - New button on Dashboard to recalculate all archive costs using current filament prices (Issue #120)
- **Create Folder from ZIP** - New option in File Manager upload to automatically create a folder named after the ZIP file (Issue #121)
- **Multi-File Selection in Printer Files** - Printer card file browser now supports multiple file selection (Issue #144):
  - Checkbox selection for individual files
  - Select All / Deselect All buttons
  - Bulk download as ZIP when multiple files selected
  - Bulk delete for multiple files at once
- **Queue Bulk Edit** - Select and edit multiple queue items at once (Issue #159):
  - Checkbox selection for pending queue items
  - Select All / Deselect All in toolbar
  - Bulk edit: printer assignment, print options, queue options
  - Bulk cancel selected items
  - Tri-state toggles: unchanged / on / off for each setting

### Fixes
- **Multi-Plate Thumbnail in Queue** - Fixed queue items showing wrong thumbnail for multi-plate files (Issue #166):
  - Queue now displays the correct plate thumbnail based on selected plate
  - Previously always showed plate 1 thumbnail regardless of selection
- **A1/A1 Mini Shows Printing Instead of Idle** - Fixed incorrect status display for A1 series printers (Issue #168):
  - Some A1/A1 Mini firmware versions incorrectly report stage 0 ("Printing") when idle
  - Now checks gcode_state to correctly display "Idle" for affected printers
  - Fix only applies to A1 models with the specific buggy condition
- **HMS Error Notifications** - Get notified when printer errors occur (Issue #84):
  - Automatic notifications for HMS errors (AMS issues, nozzle problems, etc.)
  - Human-readable error messages (853 error codes translated)
  - Friendly error type names (Print/Task, AMS/Filament, Nozzle/Extruder, Motion Controller, Chamber)
  - Deduplication prevents spam from repeated error messages
  - Publishes to MQTT relay for home automation integrations
  - New "Printer Error" toggle in notification provider settings
- **Plate Calibration Persistence** - Fixed plate detection reference images not persisting after restart in Docker deployments
- **Telegram Notification Parsing** - Fixed Telegram markdown parsing errors when messages contain underscores (e.g., error codes)
- **Settings API PATCH Method** - Added PATCH support to `/api/settings` for Home Assistant rest_command compatibility (Issue #152)
- **P2S Empty Archive Tiles** - Fixed FTP file search for printers without SD card (Issue #146):
  - Added root folder `/` to search paths when looking for 3MF files
  - Printers without SD card store files in root instead of `/cache`
- **Empty AMS Slot Not Recognized** - Fixed bug where removed spools still appeared in Bambuddy (Issue #147):
  - Old AMS: Now properly applies empty values from tray data updates
  - New AMS (AMS 2 Pro): Now checks `tray_exist_bits` bitmask to detect and clear empty slots
- **Reprint Cost Tracking** - Reprinting an archive now adds the cost to the existing total, so statistics accurately reflect total filament expenditure across all prints
- **HA Energy Sensors Not Detected** - Home Assistant energy sensors with lowercase units (w, kwh) are now properly detected; unit matching is now case-insensitive (Issue #119)
- **File Manager Upload** - Upload modal now accepts all file types, not just ZIP files
- **Camera Zoom & Pan Improvements** - Enhanced camera viewer zoom/pan functionality (Issue #132):
  - Pan range now based on actual container size, allowing full navigation of zoomed image
  - Added pinch-to-zoom support for mobile/touch devices
  - Added touch-based panning when zoomed in
  - Both embedded camera viewer and standalone camera page updated
- **Progress Milestone Time** - Fixed milestone notifications showing wrong time (e.g., "17m" instead of "17h 47m") by converting remaining_time from minutes to seconds (Issue #157)
- **File Manager Folder Navigation** - Improved handling of long folder names (Issue #160):
  - Resizable sidebar: Drag the edge to adjust width (200-500px), double-click to reset
  - Text wrap toggle: "Wrap" button in header to wrap long names instead of truncating
  - Both settings persist in localStorage
  - Tooltip shows full name on hover
- **K-Profiles Backup Status** - Fixed GitHub backup settings showing incorrect printer connection count (e.g., "1/2 connected" when both printers are connected); now fetches status from API instead of relying on WebSocket cache
- **GitHub Backup Timestamps** - Removed volatile timestamps from GitHub backup files so git diffs only show actual data changes
- **Model-Based Queue AMS Mapping** - Fixed "Any [Model]" queue jobs failing at filament loading on H2D Pro and other printers (Issue #192):
  - Scheduler now computes AMS mapping after printer assignment for model-based jobs
  - Previously, no AMS mapping was sent because the specific printer wasn't known at queue time
  - Auto-matches required filaments to available AMS slots by type and color

### Maintenance
- Upgraded vitest from 2.x to 3.x to resolve npm audit security vulnerabilities in dev dependencies

## [0.1.6b11] - 2026-01-22

### New Features
- **Camera Zoom & Fullscreen** - Enhanced camera viewer controls:
  - Fullscreen mode for embedded camera viewer (new button in header)
  - Zoom controls (100%-400%) for both embedded and window modes
  - Pan support when zoomed in (click and drag)
  - Mouse wheel zoom support
  - Zoom resets on mode switch, refresh, or fullscreen toggle
- **Searchable HA Entity Selection** - Improved Home Assistant smart plug configuration:
  - Entity dropdown replaced with searchable combobox
  - Type to search across all HA entities (not just switch/light/input_boolean)
  - Energy sensor dropdowns (Power, Energy Today, Total) are now searchable
  - Find sensors with non-standard naming that don't match the switch entity name
- **Home Assistant Energy Sensor Support** - HA smart plugs can now use separate sensor entities for energy monitoring:
  - Configure dedicated power sensor (W), today's energy (kWh), and total energy (kWh) sensors
  - Supports plugs where energy data is exposed as separate sensor entities (common with Tapo, IKEA Zigbee2mqtt, etc.)
  - Energy sensors are selectable from all available HA sensors with power/energy units
  - Falls back to switch entity attributes if no sensors configured
  - Print energy tracking now works correctly for HA plugs (not just Tasmota)
  - New API endpoint: `GET /api/v1/smart-plugs/ha/sensors` to list available energy sensors
- **Finish Photo in Notifications** - Camera snapshot URL available in notification templates (Issue #126):
  - New `{finish_photo_url}` template variable for print_complete, print_failed, print_stopped events
  - Photo is captured before notification is sent (ensures image is available)
  - New "External URL" setting in Settings → Network (auto-detects from browser)
  - Full URL constructed for external notification services (Telegram, Email, Discord, etc.)
- **ZIP File Support in File Manager** - Upload and extract ZIP files directly in the library (Issue #121):
  - Drop or select ZIP files to automatically extract contents
  - Option to preserve folder structure from ZIP or extract flat
  - Extracts thumbnails and metadata from 3MF/gcode files inside ZIP
  - Progress indicator shows number of files extracted

### Fixed
- **Print time stats using slicer estimates** - Quick Stats "Print Time" now uses actual elapsed time (`completed_at - started_at`) instead of slicer estimates; cancelled prints only count time actually printed (Issue #137)
- **Skip objects modal overflow** - Modal now has max height (85vh) with scrollable object list when printing many items on the bed (Issue #134)
- **Filament cost using wrong default** - Statistics now correctly uses the "Default filament cost (per kg)" setting instead of hardcoded €25 value (Issue #120)
- **Spoolman tag field not auto-created** - The required "tag" extra field is now automatically created in Spoolman on first connect, fixing sync failures for fresh Spoolman installs (Issue #123)
- **P2S/X1E/H2 completion photo not captured** - Internal model codes (N7, C13, O1D, etc.) from MQTT/SSDP are now recognized for RTSP camera support (Issue #127)
- **Mattermost/Slack webhook 400 error** - Added "Slack / Mattermost" payload format option that sends `{"text": "..."}` instead of custom fields (Issue #133)
- **Subnet scan serial number** - Fixed A1 Mini subnet discovery showing "unknown-*" placeholder; serial field is now cleared so users know to enter it manually (Issue #140)

## [0.1.6b10] - 2026-01-21

### New Features
- **Unified Print Modal** - Consolidated three separate modals into one unified component:
  - Single modal handles reprint, add-to-queue, and edit-queue-item operations
  - Consistent UI/UX across all print operations
  - Reduced code duplication (~1300 LOC removed)
- **Multi-Printer Selection** - Send prints or queue items to multiple printers at once:
  - Checkbox selection for multiple printers in reprint and add-to-queue modes
  - "Select all" / "Clear" buttons for quick selection
  - Progress indicator during multi-printer submission
  - Ideal for print farms with identical filament configurations
- **Per-Printer AMS Mapping** - Configure filament slot mapping individually for each printer:
  - Enable "Custom mapping" checkbox under each selected printer
  - Auto-configure uses RFID data to match filaments automatically
  - Manual override for specific slot assignments
  - Match status indicator shows exact/partial/missing matches
  - Re-read button to refresh printer's loaded filaments
  - New setting in Settings → Filament to expand custom mapping by default
- **Enhanced Add-to-Queue** - Now includes plate selection and print options:
  - Configure all print settings upfront instead of editing afterward
  - Filament mapping with manual override capability
- **Print from File Manager** - Full print configuration when printing from library files:
  - Plate selection for multi-plate 3MF files with thumbnails
  - Filament slot mapping with comparison to loaded filaments
  - All print options (bed levelling, flow calibration, etc.)
- **File Manager Print Button** - Print directly from multi-selection toolbar:
  - "Print" button appears when exactly one sliced file is selected
  - Opens full PrintModal with plate selection and print options
  - "Add to Queue" button now uses Clock icon for clarity
- **Multiple Embedded Camera Viewers** - Open camera streams for multiple printers simultaneously in embedded mode:
  - Each viewer has its own remembered position and size
  - New viewers are automatically offset to prevent stacking
  - Printer-specific persistence in localStorage
  - **Navigation persistence** - Open cameras stay open when navigating away and back to Printers page
- **Application Log Viewer** - View and filter application logs in real-time from System Information page:
  - Start/Stop live streaming with 2-second auto-refresh
  - Filter by log level (DEBUG, INFO, WARNING, ERROR)
  - Text search across messages and logger names
  - Clear logs with one click
  - Expandable multi-line log entries (stack traces, etc.)
  - Auto-scroll to follow new entries
- **Deferred archive creation** - Queue items from File Manager no longer create archives upfront:
  - Queue items store `library_file_id` directly
  - Archives are created automatically when prints start
  - Reduces clutter in Archives from unprinted queued files
  - Queue displays library file name, thumbnail, and print time
- **Expandable Color Picker** - Configure AMS Slot modal now has an expandable color palette:
  - 8 basic colors shown by default (White, Black, Red, Blue, Green, Yellow, Orange, Gray)
  - Click "+" to expand 24 additional colors (Cyan, Magenta, Purple, Pink, Brown, Beige, Navy, Teal, Lime, Gold, Silver, Maroon, Olive, Coral, Salmon, Turquoise, Violet, Indigo, Chocolate, Tan, Slate, Charcoal, Ivory, Cream)
  - Click "-" to collapse back to basic colors
- **File Manager Sorting** - Printer file manager now has sorting options:
  - Sort by name (A-Z or Z-A)
  - Sort by size (smallest or largest first)
  - Sort by date (oldest or newest first)
  - Directories always sorted first
- **Camera View Mode Setting** - Choose how camera streams open:
  - "New Window" (default): Opens camera in a separate browser window
  - "Embedded": Shows camera as a floating overlay on the main screen
  - Embedded viewer is draggable and resizable with persistent position/size
  - Configure in Settings → General → Camera section
- **File Manager Rename** - Rename files and folders directly in File Manager:
  - Right-click context menu "Rename" option for files and folders
  - Inline rename button in list view
  - Validates filenames (no path separators allowed)
- **File Manager Mobile Accessibility** - Improved touch device support:
  - Three-dot menu button always visible on mobile (hover-only on desktop)
  - Selection checkbox always visible on mobile devices
  - Better PWA experience for file management
- **Optional Authentication** - Secure your Bambuddy instance with user authentication:
  - Enable/disable authentication via Setup page or Settings → Users
  - Role-based access control: Admin and User roles
  - Admins have full access; Users can manage prints but not settings
  - JWT-based authentication with 7-day token expiration
  - User management page for creating, editing, and deleting users
  - Backward compatible: existing installations work without authentication
  - Settings page restricted to admin users when auth is enabled

### Changed
- **Edit Queue Item modal** - Single printer selection only (reassigns item, doesn't duplicate)
- **Edit Queue Item button** - Changed from "Print to X Printers" to "Save"

### Fixed
- **File Manager folder navigation** - Fixed bug where opening a folder would briefly show files then jump back to root:
  - Removed `selectedFolderId` from useEffect dependency array that was causing a reset loop
  - Folder navigation now works correctly without resetting
- **Queue items with library files** - Fixed 500 errors when listing/updating queue items from File Manager
- **User preset AMS configuration** - Fixed user presets (inheriting from Bambu presets) showing empty fields in Bambu Studio after configuration:
  - Now correctly derives `tray_info_idx` from the preset's `base_id` when `filament_id` is null
  - User presets that inherit from Bambu presets (e.g., "# Overture Matte PLA @BBL H2D") now work correctly
- **Faster AMS slot updates** - Frontend now updates immediately after configuring AMS slots:
  - Added WebSocket broadcast to AMS change callback for instant UI updates
  - Removed unnecessary delayed refetch that was causing slow updates

## [0.1.6b9] - 2026-01-19

### New Features
- **Add to Queue from File Manager** - Queue sliced files directly from File Manager:
  - New "Add to Queue" toolbar button appears when sliced files are selected
  - Context menu and list view button options for individual files
  - Supports multiple file selection for batch queueing
  - Only accepts sliced files (.gcode or .gcode.3mf)
  - Creates archive and queue item in one action
- **Print Queue plate selection and options** - Full print configuration in queue edit modal:
  - Plate selection grid with thumbnails for multi-plate 3MF files
  - Print options section (bed levelling, flow calibration, vibration calibration, layer inspect, timelapse, use AMS)
  - Options saved with queue item and used when print starts
- **Multi-plate 3MF plate selection** - When reprinting multi-plate 3MF files (exported with "All sliced file"), users can now select which plate to print:
  - Plate selection grid with thumbnails, names, and print times
  - Filament requirements filtered to show only selected plate's filaments
  - Prevents incorrect filament mapping across plates
  - Closes [#93](https://github.com/maziggy/bambuddy/issues/93)
- **Home Assistant smart plug integration** - Control any Home Assistant switch/light entity as a smart plug:
  - Configure HA connection (URL + Long-Lived Access Token) in Settings → Network
  - Add HA-controlled plugs via Settings → Plugs → Add Smart Plug → Home Assistant tab
  - Entity dropdown shows all available switch/light/input_boolean entities
  - Full automation support: auto-on, auto-off, scheduling, power alerts
  - Works alongside existing Tasmota plugs
  - Closes [#91](https://github.com/maziggy/bambuddy/issues/91)
- **Fusion 360 design file attachments** - Attach F3D files to archives for complete design tracking:
  - Upload F3D files via archive context menu ("Upload F3D" / "Replace F3D")
  - Cyan badge on archive card indicates attached F3D file (next to source 3MF badge)
  - Click badge to download, or use "Download F3D" in context menu
  - F3D files included in backup/restore
  - API tests for F3D endpoints

### Fixed
- **Multi-plate 3MF metadata extraction** - Single-plate exports from multi-plate projects now show correct thumbnail and name:
  - Extracts plate index from slice_info.config metadata
  - Uses correct plate thumbnail (e.g., plate_5.png instead of plate_1.png)
  - Appends "Plate N" to print name for plates > 1
  - Closes [#92](https://github.com/maziggy/bambuddy/issues/92)

## [0.1.6b8] - 2026-01-17

### Added
- **MQTT Publishing** - Publish BamBuddy events to external MQTT brokers for integration with Home Assistant, Node-RED, and other automation platforms:
  - New "Network" tab in Settings for MQTT configuration
  - Configure broker, port, credentials, TLS, and topic prefix
  - Real-time connection status indicator
  - Topics: printer status, print lifecycle, AMS changes, queue events, maintenance alerts, smart plug states, archive events
- **Virtual Printer Queue Mode** - New mode that archives files and adds them directly to the print queue:
  - Three modes: Archive (immediate), Review (pending list), Queue (print queue)
  - Queue mode creates unassigned items that can be assigned to a printer later
- **Unassigned Queue Items** - Print queue now supports items without an assigned printer:
  - "Unassigned" filter option on Queue page
  - Unassigned items highlighted in orange
  - Assign printer via edit modal
- **Sidebar Badge Indicators** - Visual indicators on sidebar icons:
  - Queue icon: yellow badge with pending item count
  - Archive icon: blue badge with pending uploads count
  - Auto-updates every 5 seconds and on window focus
- **Project Parts Tracking** - Track individual parts/objects separately from print plates:
  - "Target Parts" field alongside "Target Plates"
  - Separate progress bars for plates vs parts
  - Parts count auto-detected from 3MF files

### Fixed
- Chamber temp on A1/P1S - Fixed regression where chamber temperature appeared on printers without sensors in multi-printer setups
- Queue prints on A1 - Fixed "MicroSD Card read/write exception error" when starting prints from queue
- Spoolman sync - Fixed Bambu Lab spool detection and AMS tray data persistence
- FTP downloads - Fixed downloads failing for .3mf files without .gcode extension
- Project statistics - Fixed inconsistent display between project list and detail views
- Chamber light state - Fixed WebSocket broadcasts not including light state changes
- Backup/restore - Improved handling of nullable fields and AMS mapping data

## [0.1.6b7] - 2026-01-12

### Added
- **AMS Color Mapping** - Manual AMS slot selection in ReprintModal, AddToQueueModal, EditQueueItemModal:
  - Dropdown to override auto-matched AMS slots with any loaded filament
  - Blue ring indicator distinguishes manual selections from auto-matches
  - Status indicators: green (match), yellow (type only), orange (not found)
  - Shared color utility with ~200 Bambu color mappings
  - Fixed AMS mapping format to match Bambu Studio exactly
- **Print Options in Reprint Modal** - Bed leveling, flow calibration, vibration calibration, first layer inspection, timelapse toggles
- **Time Format Setting** - New date utilities applied to 12 components, fixes archive times showing in UTC
- **Statistics Dashboard Improvements** - Size-aware rendering for PrintCalendar, SuccessRateWidget, TimeAccuracyWidget, FilamentTypesWidget, FailureAnalysisWidget
- **Firmware Update Helper** - Check firmware versions against Bambu Lab servers for LAN-only printers with one-click upload
- **FTP Reliability** - Configurable retry (1-10 attempts, 1-30s delay), A1/A1 Mini SSL fix, configurable timeout
- **Bulk Project Assignment** - Assign multiple archives to a project at once from multi-select toolbar
- **Chamber Light Control** - Light toggle button on printer cards
- **Support Bundle Feature** - Debug logging toggle with ZIP generation for issue reporting
- **Archive Improvements** - List view with full parity, object count display, cross-view highlighting, context menu button
- **Maintenance Improvements** - wiki_url field for documentation links, model-specific Bambu Lab wiki URLs
- **Spoolman Integration** - Clear location when spools removed from AMS during sync

### Fixed
- Browser freeze from CameraPage WebSocket
- Project card filament badges showing duplicates and raw color codes
- Print object label positioning in skip objects modal
- Printer hour counter not updated on backend restart
- Virtual printer excluded from discovery
- Print cover fetch in Docker environments
- Archive delete safety checks prevent deleting parent dirs

## [0.1.6b6] - 2026-01-04

### Added
- **Resizable Printer Cards** - Four sizes (S/M/L/XL) with +/- buttons in toolbar
- **Queue Only Mode** - Stage prints without auto-start, release when ready with purple "Staged" badge
- **Virtual Printer Model Selection** - Choose which Bambu printer model to emulate
- **Tasmota Admin Link** - Quick access to smart plug web interface with auto-login
- **Pending Upload Delete Confirmation** - Confirmation modal when discarding pending uploads

### Fixed
- Camera stream reconnection with automatic recovery from stalled streams
- Active AMS slot display for H2D printers with multiple AMS units
- Spoolman sync matching only Bambu Lab vendor filaments
- Skip objects modal object ID markers positioning
- Virtual printer model codes, serial prefixes, startup model, certificate persistence
- Archive card context menu positioning

## [0.1.6b5] - 2026-01-02

### Added
- **Pre-built Docker Images** - Pull directly from GitHub Container Registry (ghcr.io)
- **Printer Controls** - Stop and Pause/Resume buttons on printer cards with confirmation modals
- **Skip Objects** - Skip individual objects during print without canceling entire job
- **Spoolman Improvements** - Link Spool, UUID Display, Sync Feedback
- **AMS Slot RFID Re-read** - Re-read filament info via hover menu
- **Print Quantity Tracking** - Track items per print for project progress

### Fixed
- Spoolman 400 Bad Request when creating spools
- Update module for Docker based installations

## [0.1.6b4] - 2026-01-01

### Changed
- Refactored AMS section for better visual grouping and spacing

### Fixed
- Printer hour counter not incrementing during prints
- Slicer protocol OS detection (Windows: bambustudio://, macOS/Linux: bambustudioopen://)
- Camera popup window auto-resize and position persistence
- Maintenance page duration display with better precision
- Docker update detection for in-app updates

## [0.1.6b3] - 2025-12-31

### Added
- Confirmation modal for quick power switch in sidebar

### Fixed
- Printer hour counter inconsistency between card and maintenance page
- Improved printer hour tracking accuracy with real-time runtime counter
- Add Smart Plug modal scrolling on lower resolution screens
- Excluded virtual printer from discovery results
- Bottom sidebar layout

## [0.1.6b2] - 2025-12-29

### Added
- **Virtual Printer** - Emulates a Bambu Lab printer on your network:
  - Auto-discovery via SSDP protocol
  - Send prints directly from Bambu Studio/Orca Slicer
  - Queue mode or Auto-start mode
  - TLS 1.3 encrypted MQTT + FTPS with auto-generated certificates
- Persistent archive page filters

### Fixed
- AMS filament matching in reprint modal
- Archive card cache bug with wrong cover image
- Queueing module re-queue modal

## [0.1.6b] - 2025-12-28

### Added
- **Smart Plugs** - Tasmota device discovery and Switchbar quick access widget
- **Timelapse Editor** - Trim, speed adjustment (0.25x-4x), and music overlay
- **Printer Discovery** - Docker subnet scanning, printer model mapping, detailed status stages
- **Archives & Projects** - AMS filament preview, file type badges, project filament colors, BOM filter
- **Maintenance** - Custom maintenance types with manual per-printer assignment
- Delete printer options to keep or delete archives

### Fixed
- Notifications sent when printer offline
- Camera stream stopping with auto-reconnection
- A1/P1 camera streaming with extended timeouts
- Attachment uploads not persisting
- Total print hours calculation

## [0.1.5] - 2025-12-19

### Added
- **Docker Support** - One-command deployment with docker compose
- **Mobile PWA** - Full mobile support with responsive navigation and touch gestures
- **Projects** - Group related prints with progress tracking
- **Archive Comparison** - Compare 2-5 archives side-by-side
- **Smart Plug Automation** - Tasmota integration with auto power-on/off
- **Telemetry Dashboard** - Anonymous usage statistics (opt-out available)
- **Full-Text Search** - Efficient search across print names, filenames, tags, notes, designer, filament type
- **Failure Analysis** - Dashboard widget showing failure rate with correlations and trends
- **CSV/Excel Export** - Export archives and statistics with current filters
- **AMS Humidity/Temperature History** - Clickable indicators with charts and statistics
- **Daily Digest Notifications** - Consolidated daily summary
- **Notification Template System** - Customizable message templates
- **Webhooks & API Keys** - API key authentication with granular permissions
- **System Info Page** - Database and resource statistics
- **Comprehensive Backup/Restore** - Including user options and external links

### Changed
- Redesigned AMS section with BambuStudio-style device icons
- Tabbed design and auto-save for settings page
- Improved archive card context menu with submenu support
- WebSocket throttle reduced to 100ms for smoother updates

### Fixed
- Browser freeze on print completion when camera stream was open
- Printer status "timelapse" effect after print completion
- Complete rewrite of timelapse auto-download with retry mechanism
- Reprint from archive sending slicer source file instead of sliced gcode
- Import shadowing bugs causing "cannot access local variable" error
- Archive PATCH 500 error
- ffmpeg processes not killed when closing webcam window

### Removed
- Control page
- PWA push notifications (replaced with standard notification providers)
