# Changelog All notable changes to Bambuddy will be documented in this file. ## [0.2.5b1] - Unreleased ### Added - **Slicer: process & filament profiles filtered by the selected printer (#1325, requested by @IndividualGhost1905)** — In the server-side Slice dialog, picking a printer profile now filters the Process and Filament dropdowns to presets compatible with that printer; presets that resolve to a different Bambu model drop into a trailing "Other printers" group instead of cluttering the main list. Matching uses the slicer's own `compatible_printers` list for imported (local) presets, and falls back to the `@BBL ` name suffix for cloud and standard presets, so all three tiers are covered. Compatibility-unknown presets (custom or untagged) are never hidden. Defaults follow suit — the pre-picked process and per-slot filament now prefer a printer-compatible preset, and switching the printer re-picks any selection left incompatible. The printer and process dropdowns also default to the preset names embedded in the source 3MF's `project_settings.config` when those presets are available, instead of always taking the first listed preset. New `frontend/src/utils/slicerPrinterMatch.ts` (11 unit tests) and `extract_embedded_presets_from_3mf` (5 unit tests); `UnifiedPreset` now carries `compatible_printers`, exposed for the local tier (`backend/app/api/routes/slicer_presets.py`); the plates endpoints return `embedded_printer` / `embedded_process`. Parity green, build clean. - **Spanish (es) translation (#1243, requested by @MiguelAngelLV)** — Bambuddy now ships a full European Spanish locale. New `frontend/src/i18n/locales/es.ts` translates all 4899 keys with placeholders, plural forms, and inline markup preserved; registered in `frontend/src/i18n/index.ts` and selectable as "Español" in the language picker. The parity checker auto-discovers the file — `frontend/scripts/check-i18n-parity.mjs` gained an `ES_COGNATES` allow-list for genuine Spanish cognates and brand/format tokens. Brings the supported-language count to 9 (en / de / es / fr / it / ja / pt-BR / zh-CN / zh-TW). Parity green, frontend build clean. - **Currency: Belize Dollars (BZD) added to the Settings → Cost currency dropdown (#1454, requested by @PLGuerraDesigns)** — Reporter accurately tracks 3D-printing filament costs in his local currency and BZD wasn't selectable, forcing a manual 2:1 mental conversion from USD. Added `BZD: 'BZ$'` to `frontend/src/utils/currency.ts` next to MXN (Americas dollar-prefix grouping); `getCurrencySymbol('BZD')` returns `'BZ$'` and the SUPPORTED_CURRENCIES list now has 30 entries. Unit test added in `frontend/src/__tests__/utils/currency.test.ts` covering the symbol lookup and presence in SUPPORTED_CURRENCIES; entry-count assertion bumped to 30 so any future additions/removals are caught immediately. 14 currency tests green; frontend build clean. - **Connection Diagnostic — self-service triage for "printer won't connect / won't print"** — A triage review of recently-closed issues found roughly a third were user-side setup errors (printer not in LAN developer mode, blocked ports, Docker bridge networking, wrong access code, printer on a different subnet), each costing a multi-round-trip "enable debug logging → build a support bundle → upload it" exchange. A new diagnostic (`backend/app/services/printer_diagnostic.py`) runs those checks automatically: TCP reachability of MQTT 8883 / FTPS 990 / RTSPS 322, LAN developer mode, Docker network mode, printer/host subnet match, and MQTT credential class — each returning a pass / fail / warn / skip status with a localized plain-language fix. Exposed via `GET /printers/{id}/diagnostic` (saved printer) and `POST /printers/diagnostic` (pre-save Add-Printer flow), and surfaced as a one-click "Run diagnostic" from the printer card actions menu (plus a quick button on the card when a printer is offline), the Add-Printer dialog, and a new Connection Diagnostic section on the System page. The in-app bug reporter scans configured printers when the report form opens and always shows the result — a healthy confirmation when nothing's wrong, or the detected problem and its fix inline — so setup mistakes get self-resolved instead of becoming GitHub issues. The GitHub `config.yml` troubleshooting link was repointed from the wiki source repo to the rendered troubleshooting page. Backend service unit tests (15) and frontend modal tests (3) added; all diagnostic strings translated across the 8 locales. Backend ruff clean, frontend build clean, i18n parity green. ### Changed - **Filament inventory: grouped rows now show group totals (#1368, requested by a user)** — With "Group similar" enabled, the collapsed group row showed the values of a single member (the first spool) — so a group of five 1 kg spools displayed "1000 g" instead of the 5 kg it actually held. The group header now aggregates across all members: the table view's Label, Net, Gross, Used and Remaining columns and the grid card's weight figure show group totals, while identity columns (Material, Brand, Colour) and the Cost/kg rate stay per-spool-correct. Per-spool-only fields with no meaningful total (dates, location, note, tag ID) keep showing the representative member's value; the expanded individual rows are unchanged. New `aggregateGroupSpool` helper in `frontend/src/utils/inventoryGrouping.ts` with 4 unit tests. Frontend-only — all data was already in the spool list. — Previous behaviour disabled the Slice button whenever the source 3MF's bound printer model didn't match the user's picked printer profile, on the theory that the slicer CLI "cannot re-slice a 3MF for a different printer" and would silently fall back to embedded settings to produce a wrong-printer file. Step 0 empirical test on 2026-05-20 disproved that: an 18-color H2D-bound `Trent900.3mf` sliced via the X1C bundle (`POST /slice` with `bundle=cb…X1C, printerName=# Bambu Lab X1 Carbon 0.4 nozzle`) produced 2.3 MB of genuinely X1C-compatible G-code in 1.8 s — `printer_model` overridden to `Bambu Lab X1 Carbon`, `printable_area` to 256×256 (X1C bed, not H2D's 350×320), `printable_height` 250 (vs 325), `bed_exclude_area` populated with X1C's 18×28 corner zone, `nozzle_diameter` single 0.4 (vs H2D's dual `0.4,0.4`), and the full X1C `machine_start_gcode` sequence baked in. The sidecar takes printer / process / first-N filament names from the picked bundle and only inherits embedded values for unused trailing slots — bed size, kinematics, start sequence all come from the target. **Behavioural change**: dropped `!printerMismatch` from the SliceModal `isReady` predicate so the Slice button stays enabled when models differ. The amber banner was first softened to an info message, then removed entirely — re-slicing across printers is now just a normal slice, the picker UI already shows which printer was picked, no second confirmation needed. **Dead-code removal (same drop)**: with no banner, the `source_printer_model` field on the `/library/files/{id}/plates` and `/archives/{id}/plates` responses had zero consumers; the `extract_source_printer_model_from_3mf` helper in `threemf_tools.py` (which opened the 3MF zip and read `Metadata/project_settings.config` on every plate request) had zero callers. Removed both response keys, both backend extractions, both `threemf_tools` imports, the helper itself, its 6 unit tests, the `source_printer_model` field from `frontend/src/types/plates.ts` (PlateMetadata + LibraryFilePlatesResponse), and 2 obsolete SliceModal tests that exercised the now-impossible matched-printer / legacy-archive paths. **i18n discipline cleanup (same drop, per [[feedback_no_followups]] + [[feedback_translate_dont_fallback]])**: every t() callsite in SliceModal.tsx had an inline English `defaultValue:` or positional-second-arg English fallback — 22 sites in total. With 8 locales shipped, those fallbacks are dead weight at best, and an actual i18n-violation when the key is missing because non-English users would silently see English. Audit found 3 keys (`slice.bundle`, `slice.bundleNone`, `slice.bundleAllRequired`) that had **no** corresponding entry in any locale file — they were being served from the inline English fallback exclusively, meaning every non-English user was already seeing those three labels in English. Added all 3 to all 8 locales with real translations, then stripped the English fallback from every t() call in SliceModal.tsx. The `slice.printerMismatch` key was removed from all 8 locales (banner is gone). **Why this matters**: a recurring pain point for users importing MakerWorld project files where the original creator's printer often differs from the user's; previously they had to round-trip through BambuStudio's "convert project" flow to re-export. Now Bambuddy re-slices in-place with no UI friction. **Tests**: the existing SliceModal "shows mismatch warning AND disables Slice" test was rewritten to assert "does not surface any cross-printer banner AND keeps Slice enabled when models differ" (regression guard against the gate being re-added); 2 obsolete tests deleted. 32 SliceModal tests green (was 34, -2 dead tests); 49 threemf_tools tests green (was 55, -6 helper tests); 24 plates-route tests green; frontend build clean; backend ruff clean; i18n parity check passes 4858 keys × 8 locales (net +2 vs pre-fix: +3 bundle keys, -1 printerMismatch). ### Security - **idna: bump to `>=3.15` to clear CVE-2026-45409 (ReDoS in `idna.encode()` with crafted Unicode payloads, e.g. `"٠" * N` or `"・" * N + "漢"`)** — Transitive dep pulled in by anyio / httpx / requests / yarl; not directly pinned, which is why it lingered at 3.13. Added an explicit `idna>=3.15` floor in `requirements.txt` between Authentication and HTTP-client blocks with a comment explaining why it's pinned (so a future downstream loosening doesn't silently downgrade us). Verified via `pip-audit` clean post-upgrade. - **PyJWT CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): permanently ignored in pip-audit** — Advisory is disputed by the PyJWT maintainers, with the advisory description literally noting *"this is disputed by the Supplier because the key length is chosen by the application that uses the library."* `fix_versions=[]` on the advisory confirms no PyJWT patch exists or will exist. Bambuddy is not affected: `backend/app/core/auth.py:184` auto-generates secrets via `secrets.token_urlsafe(64)` (~86 chars of entropy, far above any sane minimum) and the file-loaded path at `:177` rejects secrets shorter than 32 chars. Added a permanent `--ignore-vuln CVE-2025-45768` to `.github/workflows/security.yml` with an inline comment citing the file:line evidence so a future maintainer reviewing the ignore list sees why it's load-bearing. Also dropped the stale `--ignore-vuln CVE-2026-4539` for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified: `pip-audit --ignore-vuln CVE-2025-45768` alone reports clean). ### Fixed - **Slicer: the Process / Filament dropdowns now filter by printer using the uploaded Slicer Bundles instead of guessing from preset names (#1325, reported by @IndividualGhost1905)** — After the printer-preset pre-selection landed, the reporter found the Process Profile dropdown still showed a flat mix of `@BBL X1C` and `@BBL P2S` presets with the printer set to X1C — P2S presets that should have dropped into the trailing "Other printers" group sat in the main list. **Root cause**: `frontend/src/utils/slicerPrinterMatch.ts` resolved each cloud / standard preset's printer by parsing the `@BBL ` suffix of its name against a hard-coded `KNOWN_MODEL_CODES` allow-list. That list (`X1C, X1E, X1, P1S, P1P, A1M, A1, H2D, H2S`) was missing `P2S` (and `H2C`, `X2D`), so every `@BBL P2S` preset parsed to an empty model-code set, `presetCompatibility` returned `unknown`, and the dropdown keeps `unknown` presets in the main list (only `mismatch` moves to "Other printers"). It was a maintenance trap by construction: every new Bambu model silently broke filtering until someone edited the list. **Fix**: the name-suffix heuristic and both hard-coded model tables (`KNOWN_MODEL_CODES`, `PRINTER_NAME_PATTERNS`) are removed. Compatibility is now read from ground truth — the user's uploaded Slicer Bundles (`.bbscfg`). Each bundle is scoped to one printer and lists the process / filament presets it ships, so "process P works with printer X" holds exactly when some uploaded bundle for printer X contains P. `buildCompatibilityIndex` builds a `presetName → {printer names}` index per slot from `GET /slicer/bundles` (already fetched by the modal), and `presetCompatibility` consults it — still preferring an imported preset's own `compatible_printers` list when present. A newly released Bambu model is covered the moment its bundle is uploaded, with no code change. Presets no bundle covers stay in the main list (`unknown` is never hidden), so a user with no bundle imported sees the un-filtered list rather than a wrong one. `printerPresetCode` / `presetModelCodes` are gone; `SliceModal` passes the bundle-derived index to `PresetDropdown`, `pickProcessDefault`, and `pickFilamentForSlot` in place of the old model code. **Tests**: `slicerPrinterMatch.test.ts` rewritten — 12 tests covering `buildCompatibilityIndex` (per-printer mapping, multi-bundle union, `# ` user-clone-prefix stripping, empty-printer skip) and `presetCompatibility` (imported-tier `compatible_printers` exact match, bundle-driven match / mismatch / unknown, the #1325 P2S-into-X1C repro, no-bundles and no-printer-selected cases). 32 SliceModal tests green; frontend build clean; backend ruff clean. - **PWA: Bambuddy can now be installed as an app on Android, and the font is self-hosted (#1460, reported by @Soopahfly)** — Reporter could install Bambuddy as a PWA on desktop but not on an Android phone (Pixel 9 Pro XL, failing in both Chrome and Edge). A thorough remote-DevTools trace confirmed the manifest was valid, all icons/screenshots present, and the service worker activated, running, and controlling the page — DevTools reported no installability blockers — yet no install prompt ever appeared. **Two root causes, both Bambuddy-side.** **(1) No in-app install trigger.** Chrome for Android removed the automatic install mini-infobar in Chrome 108 (2022); since then a site must either listen for `beforeinstallprompt` and surface its own button, or the user must dig into the browser's ⋮ menu. Desktop Chrome still auto-shows the omnibox install icon, which is exactly why desktop "worked" and Android didn't. Bambuddy had no `beforeinstallprompt` handler at all, so on Android there was no discoverable install path. New `frontend/src/components/InstallAppButton.tsx` captures the `beforeinstallprompt` event (calling `preventDefault()` so the button is the single predictable entry point), re-fires it on click, shows a success toast on accept, and clears the captured prompt afterwards (a prompt can only be used once). It renders nothing when there is no pending prompt — already installed, unsupported browser, or iOS Safari (no programmatic install) — so it never shows a dead button. Added to both the expanded and compact sidebar-footer rows in `Layout.tsx`, next to the GitHub link. New `nav.installApp` / `nav.installAppSuccess` i18n keys with real translations in all 9 locales (en/de/es/fr/it/ja/pt-BR/zh-CN/zh-TW). **(2) Inter font loaded from the Google Fonts CDN.** `frontend/src/index.css` pulled Inter via `@import url('https://fonts.googleapis.com/css2?...')`. For a local-first, offline-capable PWA this is wrong: it leaks a request to Google on every load, breaks the UI font when offline, and — as the reporter's trace showed — triggered CSP console errors (`connect-src` doesn't allow `fonts.googleapis.com`). The service worker made it worse: a request to `fonts.googleapis.com/css2` has path `/css2` (no `.css` extension), so it missed the CSS branch, fell through to the catch-all HTML branch, and on failure was answered with the cached `index.html` — which is why the font request came back as `text/html`. **Fix**: the two Inter variable woff2 files (latin + latin-ext, one file covers every weight via the variable axis) are now bundled in `frontend/public/fonts/` and declared with `@font-face` in `index.css`, served same-origin. The service worker now (a) skips all cross-origin requests entirely — letting the browser handle them so a failed cross-origin fetch can never be answered with `index.html` again — (b) caches the font files in `STATIC_ASSETS` and via a `.woff2` / `/fonts/` match in the cache-first branch so the UI renders offline, and (c) bumps its cache version. With Inter self-hosted, `fonts.googleapis.com` and `fonts.gstatic.com` were dropped from the SPA and gcode-viewer CSP directives in `backend/app/main.py` (the `/docs` ReDoc/Swagger CSP keeps them — that third-party UI genuinely loads Google Fonts). Frontend build clean, i18n parity green across 9 locales, backend ruff clean, 17 security-header tests green. - **Flow Calibration now actually runs when the print option is enabled (#1478, reported by @andreirusu99)** — Reporter on an H2S saw poor extrusion around corners (classic too-high K factor); the printer's flow-dynamics calibration step never appeared in the pre-print checklist even with **Flow Calibration** toggled on in the Re-print dialog, while the same 3MF printed from Bambu Studio did calibrate. **Root causes, both in the `project_file` command built by `start_print` (`backend/app/services/bambu_mqtt.py`)**: (1) `extrude_cali_flag` was hardcoded to `0`. A BambuStudio request-topic capture from a real H2D (plus X1C and P2S captures) shows BambuStudio always sends `1` (run flow-dynamics calibration) or `2` (skip, reuse the stored PA value), paired with `flow_cali`, and **never** `0` — so the printer skipped calibration regardless of the toggle. (2) An earlier revision integer-encoded the calibration/leveling fields (`timelapse`, `bed_leveling`, `flow_cali`, `vibration_cali`, `layer_inspect`) for the H2 family (H2D/H2S/H2C/X2D) on the belief that H2 firmware required `0/1`; the same H2D capture disproves this — BambuStudio sends plain JSON booleans for every model. The "integer required" claim conflated these fields with `use_ams`, which genuinely must stay boolean (H2D Pro reads an integer `use_ams` as a nozzle index — the actual #1386 cause). **Fix**: `extrude_cali_flag` is now `1 if flow_cali else 2`, and the five calibration/leveling fields are sent as JSON booleans for all models, matching BambuStudio's wire format exactly. The H-family integer-conversion branch (`is_h_family`) is removed. `use_ams` is unchanged. This affects only the outbound print command; the virtual-printer inbound coercion of slicer integer `0/1` flags (#1403) is a separate path and untouched. **Tests**: in `test_bambu_mqtt.py`, the two tests that asserted the integer format (`test_x2d_uses_integer_format_for_calibration_fields`, `test_h2s_keeps_integer_format_for_calibration_fields`) are corrected to assert booleans and renamed; all three model tests (X2D/H2S/P2S) now also assert `extrude_cali_flag` is `1` when flow cali is on and `2` when off. 381 mqtt + virtual-printer tests green; backend ruff clean. - **OpenSpoolman-tagged spools are now selectable in the AMS-slot assignment picker (#1122, reported by @mithkr)** — Reporter runs Bambuddy alongside OpenSpoolman. OpenSpoolman writes a generated NFC tag value into the Spoolman `spool.extra.tag` field; any spool it had tagged then never appeared in Bambuddy's "Select a spool" picker (`LinkSpoolModal`), so a spool that was *physically* unassigned could not be linked to an AMS tray. **Root cause**: `GET /spoolman/spools/unlinked` (`backend/app/api/routes/spoolman.py`) classified a spool as "linked, hide it" purely on *presence of a non-empty `extra.tag`*. That was a stale proxy. `extra.tag` is only an RFID/NFC *matching key* — both Bambuddy and OpenSpoolman write a tag identifier there, for the same purpose — and its presence says nothing about whether the spool occupies an AMS slot. Bambuddy already has a dedicated ledger for that: the `spoolman_slot_assignments` table, which `models/spoolman_slot_assignment.py` itself documents as "the source of truth for Spoolman slot assignments". **Fix**: `get_unlinked_spools` now decides assignability from that ledger — a spool is assignable iff its id is **not** in `spoolman_slot_assignments` — and ignores `extra.tag` entirely. Verified the ledger is complete: both `link_spool` (manual link) and the AMS auto-sync (`spoolman.py` slot-change persistence) upsert a row for every occupied slot, so nothing genuinely assigned can leak back into the picker. `get_linked_spools` and `find_spool_by_tag` keep using `extra.tag` unchanged — those are genuine tag-match maps and are unaffected. Internal-inventory mode needs no parallel change: it stores tags in its own DB with no Spoolman `extra` collision, so the OpenSpoolman conflict cannot occur there. Visible behavior change: a spool Bambuddy linked but whose slot assignment was later cleared now re-appears as assignable — correct, since it is genuinely re-linkable. **Tests**: `test_spoolman_api.py` — `test_get_unlinked_spools_success` now asserts a spool with a non-empty OpenSpoolman-style `extra.tag` but no slot row still appears in the picker; new `test_get_unlinked_spools_excludes_slot_assigned` seeds a `SpoolmanSlotAssignment` row and asserts that spool is excluded while a tagged-but-unassigned spool is included. 50 spoolman-API integration tests green; backend ruff clean. - **Missing-spool-assignment notification no longer false-fires on every Spoolman-mode print (#1473, reported and root-caused by @ojimpo)** — Reporter on Spoolman mode (AMS 2 Pro, all four trays bound to Spoolman spools via the Assign-Spool UI) got a `print_missing_spool_assignment` notification on every print start — 13 false positives in 7 days — each flagging trays that were correctly bound. He traced it precisely: `backend/app/services/spool_assignment_notifications.py` queried only the legacy `SpoolAssignment` table, never `SpoolmanSlotAssignment`. In Spoolman mode the legacy table is empty (bindings live in `spoolman_slot_assignments`, the source-of-truth since #1119), so `assigned_global_trays` came back empty and every used tray was reported missing. Same class of miss as #1459 (the weight tracker also skipped `SpoolmanSlotAssignment`) and a [[feedback_inventory_modes_parity]] violation — a check present in both modes was wired only for legacy. **Fix**: the assigned-tray set is now the **union** of both tables — `SpoolAssignment` and `SpoolmanSlotAssignment` rows for the printer. Both expose `printer_id` / `ams_id` / `tray_id` in identical shape (verified against `models/spoolman_slot_assignment.py`, whose `ams_id` range 0-7 / 128-191 / 255 is fully covered by the existing `_global_tray_from_assignment()`), so the helper works on either unchanged. The union is strictly safe: it can only *add* assignments, so it never regresses legacy-mode behavior and never reports a genuinely-unassigned tray as covered. Scope note: this does not add RFID-`extra.tag` resolution (a tray bound purely via the loaded spool's RFID tag with no slot-assignment row) — that needs the Spoolman client and is a deeper change; the reported false positive is entirely covered by the union since the Assign-Spool UI writes `SpoolmanSlotAssignment`. **Tests**: 3 new in `test_spool_assignment_notifications.py` (the reporter's suggested cases) — Spoolman-only binding suppresses the notification; Spoolman partial coverage flags only the uncovered tray; mixed-mode (A1 legacy + A2 Spoolman) union covers all used trays. The test fake now routes `execute()` by target table so either mode can be exercised; the existing legacy-mode test still passes unchanged. 4 notification tests green; backend ruff clean. **Audit follow-up**: a sweep of every `SpoolAssignment` consumer confirmed the other internal-mode-only users (`usage_tracker.py`, `spool_tag_matcher.py`, `routes/inventory.py`) are correct — internal and Spoolman modes have parallel implementations by design — but surfaced an asymmetry in `routes/settings.py`: the Spoolman-mode toggle cleared `SpoolAssignment` when switching *on* but never cleared `SpoolmanSlotAssignment` when switching *off*, so stale Spoolman rows lingered. Harmless before, but now that the notification unions both tables those stale rows would wrongly count as "assigned" in internal mode and suppress a legitimate warning. Added the symmetric clear — switching back to internal mode now deletes `SpoolmanSlotAssignment` rows, mirroring the existing on-switch behavior. 1 integration test in `test_spoolman_slot_assignments.py::TestModeSwitchClearsAssignments` covers it; 23 slot-assignment + 45 settings/slot tests green. - **Local Profiles: the search bar no longer disappears when a query matches nothing (#1470, reported by @pwostran)** — Typing a query in Settings → Local Profiles that matched no preset made the search bar itself vanish, leaving the user unable to clear or edit the query without a full page refresh. Root cause in `frontend/src/components/LocalProfilesView.tsx`: the search bar was gated on `{totalCount > 0 && …}`, and `totalCount` is the sum of the *post-filter* `filaments` / `printers` / `processes` lengths — so the moment the query filtered every column to empty, `totalCount` hit 0 and the search bar unmounted along with the columns. The `totalCount === 0` "No local presets yet" empty state then took over, which also misleadingly implied nothing was imported. **Fix**: added `hasAnyPresets`, computed from the *pre-filter* preset counts (`presets?.filament/printer/process` lengths), and gated the search bar on that instead — it stays mounted as long as any preset exists, regardless of the query. The empty state is now split: `!hasAnyPresets` shows the genuine "No local presets yet" + import hint, while `hasAnyPresets && totalCount === 0` shows a new "No presets match your search" message (with a search icon) so the two cases are no longer conflated. New `noSearchResults` i18n key added with real translations in all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). **Tests**: 1 new in `LocalProfilesView.test.tsx` — types a non-matching query and asserts the search bar is still in the DOM, retains the typed value, and the no-matches message renders. 10 LocalProfilesView tests green; i18n parity 4859 keys × 8 locales; frontend build clean. - **Failure Detection: the Status panel's Low / High thresholds now reflect the selected sensitivity (#1469, reported by @JohnMacOB)** — Reporter changed the Sensitivity dropdown (Low / Medium / High) in Settings → Failure Detection and the "Low / High thresholds" readout in the Status panel never moved off `0.38 / 0.78`, so the setting looked dead. **Detection itself was always correct** — the classifier at `backend/app/services/obico_detection.py:280` uses `classify(score, settings["sensitivity"])` with the real value, so warnings/failures triggered at the right confidence for the chosen level. The bug was display-only: `ObicoDetectionService.get_status()` computed the displayed thresholds with a hardcoded `thresholds("medium")` (`obico_detection.py:324`), ignoring the configured sensitivity. `thresholds()` is `BASE × SENSITIVITY_MULT` — low ×1.25 → `0.48 / 0.98`, medium ×1.0 → `0.38 / 0.78`, high ×0.75 → `0.29 / 0.59` — so the panel always showed the medium row whatever the user picked, making a working setting look broken. **Fix**: `get_status()` takes an optional `sensitivity` parameter (default `"medium"`, so `thresholds()`'s own unknown-value fallback still applies) and the `/obico/status` route — which already loads settings fresh and has `settings["sensitivity"]` in hand — passes it through. The readout now updates the instant the dropdown change is saved (the frontend already invalidates the `obico-status` query on save), with no wait for the next poll cycle. **Tests**: 1 new in `test_obico_detection.py::TestGetStatus` — `test_thresholds_reflect_configured_sensitivity` asserts low > medium > high for both threshold bounds and that the default / unknown sensitivity falls back to medium. 47 obico unit tests + 5 obico API integration tests green; backend ruff clean. - **Printer serial numbers are normalized on input, and a stale connection that never receives a status report now logs an actionable hint (#1465, reported by @jmneely94)** — Reporter's H2C connected over MQTT+TLS without error but every status field stayed `unknown` (state, firmware, AMS, wifi); the P1S on the same instance worked. The report concluded the H2C firmware doesn't publish MQTT — but its own evidence disproves that: Bambu Studio LAN mode showed the H2C's live status, and Bambu Studio's device telemetry *is* MQTT `device//report` (the "HTTPS API, not MQTT" claim in the report is incorrect — only file transfer/FTP and the camera stream are non-MQTT). A printer visible in Studio is publishing. **Actual cause is layer-8**: connect-OK + subscribe-OK + zero messages forever means Bambuddy subscribed to a topic with no traffic — the MQTT broker *is* the printer, it authenticates on the access code and SUBACKs a subscription to *any* topic string, so a wrong or mis-cased serial connects fine and silently receives nothing. The reporter's own `mosquitto_sub` "verification" subscribed to `device/31b8c…/report` lowercase; MQTT topics are case-sensitive and Bambu serials are uppercase, so that test reproduced the mistake rather than validating the firmware theory. Bambuddy did nothing to guard against it: `schemas/printer.py` took `serial_number` as a bare string, the model stored it verbatim, and `bambu_mqtt.py` built the topic as `f"device/{self.serial_number}/report"` with no `.upper()` / `.strip()`. **Two hardening changes** so this class of mistake self-heals or at least diagnoses itself. **(1) Serial normalization**: a `field_validator` on `PrinterBase.serial_number` now `.strip().upper()`s the value (rejecting blank-after-strip), so a serial pasted in the wrong case or with stray whitespace produces the correctly-cased subscription topic. `PrinterUpdate` has no `serial_number` field so the create path is the only entry point; existing DB rows are not migrated (forward fix — a mis-cased existing printer is corrected by re-adding it). **(2) Zero-report diagnostic**: `BambuMQTTClient` now counts report-topic messages per connection (`_report_messages_since_connect`, reset in `_on_connect`, incremented in `_on_message` when `msg.topic == self.topic_subscribe`). When `check_staleness()` fires its reconnect and that counter is still 0, it logs a one-shot WARNING (`_zero_report_hint_logged` guards against spamming the 60-90s reconnect loop) telling the user the most common cause is a wrong/mis-cased serial and that the report topic is case-sensitive — turning a silent indefinite reconnect loop into something actionable in the log / support bundle. Known gap left intentionally: a printer that connects but sends *literally* zero messages (so `_last_message_time` stays 0) never trips `is_stale()` at all — that grace behavior is #887-sensitive and out of scope here; the diagnostic covers the observed case where staleness does fire. **Tests**: 5 in new `test_printer_schema.py` (uppercase, whitespace-strip, both, already-normalized no-op, blank rejected); 2 in `test_bambu_mqtt.py::TestStaleReconnect` (hint logs once when no reports received then stays silent on the next stale cycle; no hint when reports were received — a normal mid-session quiet gap). 659 printer/MQTT/scheduler tests green across the affected suites; backend ruff clean. - **Smart-plug "Auto Off after Drying" no longer kills the printer seconds into a drying cycle (#1462, reported by @Kyobinoyo)** — Reporter on an X2D (firmware 01.01.00.00) set a 1-hour AMS dry and a short auto-off-after-drying delay, and the printer powered off almost immediately. The support bundle made it unambiguous: every `Sent drying command … duration=1` was followed 3-9 seconds later by `AMS 0 drying complete (dry_time 60 → 0)` — the drying-complete callback fired seconds after drying *started*, not when it finished, arming smart-plug auto-off against a printer that was still drying (and potentially printing). The reporter's hypothesis was a missing print-state check; the actual cause is a **false completion detection**. **Root cause — partial AMS-update merge drops `dry_time`**: `backend/app/services/bambu_mqtt.py` merges partial AMS MQTT updates. The no-tray branch correctly preserved top-level fields (`{**existing_unit, **ams_unit}`), but the tray-bearing branch rebuilt the unit as `{**ams_unit, "tray": merged_trays}` — spreading only the *new* partial, never `existing_unit`. The printer constantly sends tray-bearing partials that carry no drying fields, so on every such update `dry_time` (and `info`, which drives `dry_status` / `dry_sub_status`) was silently dropped. The drying falling-edge detector then read `int(ams_unit.get("dry_time") or 0)` → field absent → `current = 0`; with `previous` a real countdown value (60, 50, 52 in the reporter's log) the `previous > 0 and current == 0` check fired a false "drying complete". **Fix, two parts.** **(1)** Tray-bearing merge branch now spreads `existing_unit` first — `{**existing_unit, **ams_unit, "tray": merged_trays}` — so `dry_time`, `info`, humidity, temp and any other top-level field a partial omits survive the merge, matching the no-tray branch. This also fixes `dry_status` / `dry_sub_status` flapping in the UI on every tray update (same dropped-field bug, broader symptom). **(2)** Defence-in-depth in the falling-edge detector: it now only evaluates the edge when `dry_time` is *explicitly present* (`ams_unit.get("dry_time")` not None) and parseable — an absent or unparseable value is skipped without touching `_previous_dry_times`, so a missing field can never be read as "drying finished" even if a future merge regression re-introduces a drop. Once detection is correct, `on_drying_complete` only fires at real completion, so the auto-off timer arms when the user expects. **Tests**: 1 new in `test_bambu_mqtt.py::TestDryingCompleteCallback` — `test_tray_only_partial_does_not_fake_completion` pushes `dry_time=60`, then a tray-only partial with no `dry_time`, asserts no event fired AND `state.raw_data["ams"][0]["dry_time"]` still equals 60, then a real `dry_time=0` push fires the edge exactly once. 108 drying/AMS tests + 35 smart-plug-manager tests green; backend ruff clean. - **Scheduler: queue items with `force_color_match` filament overrides now produce a correct AMS mapping at dispatch (#1437, fixed by external PR #1440 from @Person2099)** — Contributor's own bug report and fix. He had a queue item with `filament_overrides: [{slot_id: 1, type: "PLA", color: "#CBC6B8", force_color_match: true}]` and `ams_mapping: null`, expecting Bambuddy to translate the override into a slot mapping at dispatch time. Instead the scheduler dispatched with `ams_mapping: null` and the P1S fell back to type-only AMS matching, picking the wrong-colour slot. **Two-layer root cause** he traced end-to-end. **(1) `backend/app/services/filament_requirements.py:69`**: `extract_filament_requirements(file_path, plate_id=None)` fell through to `_collect_filaments(root, filaments)` whose XPath `./filament` only matches direct children of ``. Modern BambuStudio 3MFs wrap filaments inside `` elements, so this XPath returned `[]` on every modern multi-plate 3MF when no specific plate was targeted — which is the standard scheduler call shape for queue items without a pinned plate. The downstream "no AMS mapping" cascade ALL flowed from this empty filament_reqs result. Fix walks `` elements first, dedupes by `slot_id` (highest `used_grams` wins on ties — sane because BambuStudio slots are project-wide and the entry that extruded the most is the most representative for AMS planning), and preserves the old `./filament` XPath as a fallback when no `` elements are present, so legacy 3MFs continue to parse unchanged. **(2) `backend/app/services/print_scheduler.py:792` — defence in depth**: even with (1) in place, edge cases exist where `_get_filament_requirements` can still return None (3MF missing `slice_info.config` entirely, IO failure during ZIP extraction, etc). New `_build_override_direct_mapping(force_overrides, status)` helper kicks in at exactly that moment when `force_color_match` overrides are present — builds the requirement list directly from the overrides (`slot_id`, `type`, `color`, empty `tray_info_idx`) and delegates to the existing `_match_filaments_to_slots()` cascade against the printer's loaded AMS state. Wrong-colour slot credit via the cascade's type-only fallback is impossible-by-construction because the upstream `_get_missing_force_color_slots()` printer-eligibility gate at `:590` already requires an exact `(type, normalised colour)` pair to be loaded *before* the printer is even considered for the job, so by the time `_build_override_direct_mapping` runs the exact match is guaranteed in the loaded set and the cascade's `exact_match` branch wins (colour normalisation is identical on both sides — `tray_color.replace("#", "").lower()[:6]`). Pref-only overrides (without `force_color_match`) intentionally do NOT trigger the fallback — they keep the pre-PR "no mapping, printer picks defaults" behaviour, so the new fallback is strictly opt-in via `force_color_match: true`. **Backwards-compat triple-checked**: legacy 3MF format unchanged (preserved fallback path); `plate_id != None` branch untouched (entire fix is inside the `else` of `if plate_id is not None`); `filament_overrides=None` / `[]` / no-force-entries all preserve the existing `return None` path; malformed JSON in `filament_overrides` is caught by the existing try/except, logged, and still returns None. **Tests** (22 new across two files; all pass on `pytest -n 30`): `backend/tests/unit/services/test_filament_requirements.py` — 4 tests covering the `plate_id=None` modern-format path, multi-plate collection, slot-dedup-by-highest-grams, and single-plate-modern-format. `backend/tests/unit/test_scheduler_force_color_ams_fallback.py` — 18 tests across `TestBuildOverrideDirectMapping` (single override matches AMS slot, empty AMS returns None, no colour match still produces a mapping length, multi-override produces multi-element mapping, external spool match yields global_tray_id 254, `tray_info_idx` is cleared) and `TestComputeAmsMappingFallback` (fallback used when reqs empty + force overrides present, fallback NOT used when no force_color flag, fallback NOT used when overrides None, normal path still used when reqs available, printer-status-unavailable returns None gracefully). 5079 backend tests + ruff + frontend build all clean post-merge; #1457/#1459/#1440 verified non-interacting (different services, different code paths, different timings). External-PR-checklist (per [[feedback_pr_changelog_required]]): contributor doesn't add CHANGELOG, this entry added by Martin post-merge. - **Spoolman: per-print weight reporting now works for tag-less spools assigned via the Bambuddy UI (#1459, reported by @Moskito99 — follow-up to #1119)** — Reporter on Postgres + Postgres-backed Spoolman noticed that prints finished cleanly but the spool's remaining weight in Spoolman was never decremented. He correctly traced it: Spoolman's `extra.tag` on his spool was empty, and writing a value in there by hand made weight tracking start working. **Root cause** is one missing fallback path. After #1119 introduced the local `spoolman_slot_assignments` table as the authoritative binding for tag-less spools (RFID is the binding for Bambu Lab spools, slot-assignment is the binding for generic / non-RFID spools), the Assign UI deliberately leaves Spoolman's `extra.tag` field empty for those spools — and after the #1457 cleanup we now actively *clear* it on re-binding to stop ghost links resurfacing in the hover card. That's the correct write-side behaviour. But the per-print weight tracker (`backend/app/services/spoolman_tracking.py:_report_spool_usage_for_slots`) only resolved the bound spool via `client.find_spool_by_tag(spool_tag)` — a single tag-lookup against Spoolman's `extra.tag`. For tag-less spools that returns None and the tracker silently skipped the slot. The tracker **never consulted the local `spoolman_slot_assignments` table** that has the answer (verified: `grep -n SpoolmanSlotAssignment backend/app/services/spoolman_tracking.py` returned zero hits before this fix). So Bambu Lab RFID users got correct weight reporting (their `extra.tag` is auto-populated by the AMS-sync `create_spool` path at `backend/app/services/spoolman.py:1076`), and generic-spool users on Spoolman saw weight tracking silently no-op — exactly the symptom Moskito99 saw. **Fix** adds a two-stage resolver inside `_report_spool_usage_for_slots`: stage 1 is the existing `client.find_spool_by_tag(spool_tag)` (RFID and any RFID-equivalent `extra.tag` value), stage 2 is the new `_resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id)` helper that queries the `SpoolmanSlotAssignment` table for `(printer_id, ams_id, tray_id) → spoolman_spool_id`. The (ams_id, tray_id) pair is derived from the slot's global_tray_id via the existing `_global_tray_id_to_ams_slot` helper — same translation used for fallback-tag generation, so external slots (global 254/255 → ams_id=255, tray_id=0/1) and AMS-HT slots (global 128+ → ams_id=global, tray_id=0) all resolve correctly. Stage-1-wins ordering is deliberate: when an RFID-bound spool is in the slot, `extra.tag` is the authoritative binding, even if the slot-assignment table happens to point at a different spool (legacy state). The resulting `[SPOOLMAN] … via tag` vs `… via slot-assignment` suffix in the success log makes it obvious which path resolved each slot, which support bundles will use to confirm the fix is live. `printer_id` threaded through the three callers (`_report_partial_usage` G-code path, `_report_partial_usage` linear path, `report_usage`) — they all already had `printer_id` in scope. Crucially, **`extra.tag` is NOT auto-populated** by this fix — that would re-introduce exactly the pollution #1457 cleaned up (deterministic fallback tags surviving across spool changes and surfacing stale spools in the hover card). The slot-assignment table is the source of truth for non-RFID bindings; Spoolman's `extra.tag` is reserved for hardware RFID identifiers. **Tests:** 5 new in `backend/tests/integration/test_spoolman_tracking_slot_fallback.py`: the bug repro (tag missing + slot-assignment present → use_spool by the slot-assignment's id); tag-match wins when both present (a regression that flips the resolution order would credit the wrong spool); skip-when-neither (no spool resolution attempted); skip-when-printer_id-not-supplied (legacy call shape stays inert); external-slot translation (global 254 → ams_id=255 tray_id=0 lookup works). New `patch_async_session` fixture routes the tracker's module-level `async_session` to the test engine so the in-test `SpoolmanSlotAssignment` insert is visible to the lookup. **Postgres compatibility:** verified — the lookup uses a plain `select(...where...).scalar_one_or_none()`, no SQLite-only syntax. 642 spoolman/tracking tests + 5 new = 647 green; full backend suite 5065 green; ruff clean. - **Spoolman: AMS hover card and SpoolBuddy fill-bar no longer surface a stale spool after re-assigning a non-RFID slot (#1457, reported by @Menthe11)** — Reporter on a P1S with generic (non-RFID) PLA saw two different spools rendered in the AMS hover card: the top "Spulen-ID / Im Inventar öffnen" link pointed at an almost-empty black PLA spool that had been in the slot weeks earlier, while the bottom "Zugewiesen" block correctly showed the full spool the user had just assigned via Spoolman. **Root cause is two-layered.** For non-RFID slots Bambuddy falls back to a deterministic per-slot tag (`hash(printer_serial) + ams_id + tray_id`, 16 hex chars; see `frontend/src/utils/amsHelpers.ts:176`). When a user runs Link UI on such a slot, that fallback tag is written to the Spoolman spool's `extra.tag` — and the existing Link / Assign routes never cleared it from the previous holder when the user re-bound the slot to a different spool. The frontend's hover-card resolver at `frontend/src/pages/PrintersPage.tsx:3736` (and the matching sites at `:4137` / `:4452` for HT and external slots) then preferred that stale tag-link over the user's explicit slot-assignment: `linkedSpoolId: (trayTag ? linkedSpools?.[trayTag]?.id : undefined) ?? slotAssignmentForFill?.spoolman_spool_id`. So when both layers existed and they disagreed, the *stale* spool won, and FilamentHoverCard's dedupe at line 377 couldn't collapse the two buttons because the IDs didn't match → two "Im Inventar öffnen" buttons pointing at different spools. The SpoolBuddy AMS page had the identical bug shape in two more spots: `getSpoolmanFillForSlot()` (the per-slot fill-percentage resolver, line 138) walked tag-link before slot-assignment, so the fill bar reported the *old* spool's remaining grams instead of the freshly assigned full one; and the slot-action picker's "Linked spool" / "Assigned spool" branches (line 760) showed "Linked spool" whenever a tag-link existed, regardless of whether a (more recent) slot-assignment also existed. **Fix has two parts.** (1) Frontend precedence swap at all five sites: slot-assignment is the user's most explicit, most recent action — it must outrank the tag-link, which is auto-populated and can be silently stale. With the swap, FilamentHoverCard's existing match-dedupe collapses both buttons into one pointing at the correct spool; SpoolBuddy's fill bar reads from the assigned spool's weight first; and SpoolBuddy's slot-action picker drops the stale "Linked spool" line entirely when a slot-assignment exists. (2) Backend hygiene so the stale state is never written in the first place: a new `_clear_stale_tag_links(client, tag, keep_spool_id, log_context)` helper in `backend/app/api/routes/spoolman_inventory.py` enumerates Spoolman spools and PATCHes `extra.tag` to JSON-empty (`'""'`, the same wire shape `unlink_spool` already uses so the read-side `.strip('"')` filter in `get_linked_spools` skips it) on any spool *other than* the one being bound that still claims the same tag. Wired into `POST /spoolman/inventory/slot-assignments` (computes the slot's deterministic fallback tag via the existing `get_fallback_spool_tag_for_slot` helper in `spoolman_tracking.py` — newly promoted to a public symbol that mirrors the frontend's `getFallbackSpoolTag(serial, amsId, trayId)` signature) and `POST /spoolman/spools/{id}/link` (passes the literal `spool_tag` being bound — works for both RFID tags and fallback tags). Both are best-effort: per-spool patch failures and Spoolman enumeration failures are logged and skipped, never raised, so the assign/link path never wedges on a Spoolman hiccup. Existing assign-route tests stay green because their fixtures' Spoolman client mock already had `get_spools` returning `[]` (or now does — fixture updated in `test_spoolman_slot_assignments.py`, `test_spoolman_slot_concurrency.py`, `test_spoolman_slot_assignment_mqtt.py`, and the link-route test fixture in `test_spoolman_api.py`). **Tests** (8 new in `backend/tests/unit/test_spoolman_stale_tag_cleanup.py`): clears one other-spool while keeping the bound spool and unrelated-tag spool intact; case-insensitive match (the helper uppercases both sides because `get_linked_spools` already does); empty-tag short-circuits without enumerating spools; `keep_spool_id` guards against clearing the spool being bound; Spoolman 5xx during enumeration is swallowed and the call returns 0; one per-spool patch failure doesn't abort the rest of the cleanup; the slot-fallback wrapper computes the right tag and clears it; empty serial returns 0 without enumerating. Backend: ruff clean, 581 spoolman tests + 8 new = 589 green. Frontend build clean. - **AMS drying popover no longer renders off the bottom of the viewport + diagnostic logging for the silent-drying-ignore bug (#1447, reported by @kleinweby)** — Two distinct bugs in the same report, both shipped in this PR. **(1) Popover positioning**: reporter on P1S + AMS-HT couldn't see the Start button on the drying popover and worked around it via DevTools to confirm the popover was actually there, just clipped below the fold. Root cause in `frontend/src/pages/PrintersPage.tsx:3498 / :4011` (two identical sites — one for the compact AMS row, one for the dual-nozzle layout): the flame-icon onClick computed popover position as a fixed `{ top: rect.bottom + 4, left: Math.max(8, rect.right - 240) }` with no viewport-overflow check. The flame icon sits at the bottom of the AMS info section on the printer card, so on most realistic viewports `rect.bottom + 4 + popover_height(~320px) > viewport.height` and the popover rendered partially or entirely off-screen. Fix extracts a `computePopoverPosition()` helper in `frontend/src/utils/popoverPosition.ts` that defaults to placing the popover below + right-aligned to the trigger (preserving the original visual layout), flips ABOVE the trigger when below would overflow AND above would fit, stays below in the degraded case where neither fits (popover taller than viewport — at least the top is visible and the user can scroll inside), and clamps the left coordinate so a trigger near either viewport edge can't push the popover off-screen horizontally either. Both PrintersPage callsites now go through the helper. **(2) Diagnostic logging for the silent-drying-ignore**: reporter's support bundle showed the printer receives every `ams_filament_drying` command (multiple start / stop attempts on `ams_id=128`, P1S 01.10.00.00 firmware), the printer ACKs each one, but the AMS info field never changes — drying neither starts nor stops on Bambuddy's request, while pressing Start on the printer's touchscreen worked immediately (so the hardware path is healthy and the LAN MQTT channel is delivering). The Bambuddy command JSON matches the format documented as working on H2D, all required fields are present, types match BambuStudio. Diagnosing the silent rejection needs the printer's actual response payload — whether `result: "fail"` and the specific `reason` code — but `bambu_mqtt.py:918` was only logging the response *command name*, not the body. The existing `extrusion_cali_*` / `ams_filament_setting` debug path at `:919-920` was the template; this PR extends it to `ams_filament_drying` at **INFO level** specifically (not DEBUG like its siblings) because drying responses are rare — user-initiated only — and INFO ensures the body lands in support bundles by default without needing the user to bump log level first. Paired with a matching outgoing-side INFO log inside `send_drying_command` that captures the full wire JSON, so the next support bundle has **both halves of the conversation**. The actual command-side fix can't happen without that data (no guessing — flipping `close_power_conflict: true` or otherwise mutating a field that matches the documented-working H2D shape could break currently-working installs). When kleinweby retries on this build and re-attaches a bundle, the rejection reason is visible and the command-side fix follows from real data. **Tests** (8 new in `__tests__/utils/popoverPosition.test.ts`): below-has-room places below; right-align to trigger; below overflows flips above; degraded case stays below; clamps right-edge and left-edge triggers; respects custom margin and gap. 276 backend service tests + frontend build clean. - **Stats: Print Activity heatmap buckets prints by local date, not UTC date (#1446, reported and root-caused by @needo37)** — Reporter on CDT (UTC-5) noticed that prints finished in the local evening were jumping to "tomorrow's" cell on the GitHub-style contribution heatmap on the Stats page. He went through `frontend/src/components/PrintCalendar.tsx` and identified the root cause: line 30 split the raw ISO string on `'T'` to get a YYYY-MM-DD key, which always returns the **UTC** date — but the cell tooltip (line 161) rendered via `toLocaleDateString()`, which is **local-tz aware**. Same data, two renderers, only one was tz-correct. He confirmed with DB query: rows 29 and 30 stored as `2026-05-18 ... UTC` were both local `May 17` (20:46 CDT and 22:39 CDT), and the Archives → Print Log view formatted them correctly as May 17 via `toLocaleString()` while the heatmap split them onto May 18 via the raw-ISO shortcut. The component had two more instances of the same shape that I caught while applying the fix: line 152 built the per-cell lookup key via `day.toISOString().split('T')[0]` (the `day` Date objects produced by the calendar-generation loop are local-tz constructed via `new Date()` + `setDate`, so `toISOString()` shifted them back to UTC before the lookup — would have re-broken the join even after the bucketing fix), and line 154's "today" highlight comparison used `new Date().toISOString().split('T')[0]` too (so at e.g. 23:00 CDT the heatmap would have ringed UTC-tomorrow's cell instead of local-today's). **Fix** adds a `localDateKey(input: string | Date): string` helper in `frontend/src/utils/date.ts` that wraps `parseUTCDate()` and formats via the local-tz getters (`getFullYear` / `getMonth` / `getDate` with two-digit padding), returning a stable comparable YYYY-MM-DD string. PrintCalendar.tsx uses it in all three spots — bucket key for input ISO strings, grid-cell lookup key, and "today" highlight — so the bucketing, the cell join, and the today ring all live on the same local-tz axis as the user's tooltip label. Backend stays UTC (`PrintLogEntry.created_at` unchanged); bucketing is a presentation concern and the browser already knows the user's tz. The reporter's broader point ("same fix needed anywhere else the frontend buckets timestamps to days") still has stragglers — `StatsPage.tsx:55-84` (computeDateRange) builds the dateFrom/dateTo strings for backend stats queries using `getUTC*` getters everywhere, so a "this week" picked at 23:00 local on Sunday in CDT sends UTC-Monday-based ranges to the backend; that's a separate, deeper bug because it also requires the backend to filter on a tz-shifted UTC range, and Bambuddy has no user-tz setting model today. Punted with a `localDateKey` helper available for reuse when that work lands. **Tests** (5 new in `__tests__/utils/date.test.ts`): keys a local-evening Date to its local date (the bug repro), reproduces the reporter's row-30 case (a moment whose UTC date is "tomorrow" keys to local "today"), pads single-digit month/day, handles null / undefined / empty defensively, and accepts both Date and ISO-string inputs end-to-end via `parseUTCDate`. 74 date-util tests green; frontend build clean. Tests are written tz-independently — they construct `new Date(2026, 4, 17, 22, 0, 0)` via the local-time constructor form so they assert correctly regardless of which tz the CI runner happens to be in. - **Printers: Add Printer no longer hangs the container on P1S (#1445, reported by @psybernoid and confirmed by @thomassjogren)** — Regression introduced in 0.2.4.2 by the `fix(printers): refuse to add a printer when the MQTT probe fails` change (b51598ea). That commit added a pre-insert MQTT probe to `POST /printers/` via `printer_manager.test_connection()` to catch mistyped access codes before persisting an empty card — but the probe had two compounding bugs that bit P1S specifically. First, a fixed `await asyncio.sleep(2)` checked `state.connected` exactly once at t=2s: P1S firmware's broker / TLS handshake routinely needs 3–5s to surface a CONNACK on a cold MQTT session (same firmware family that already has the documented "broker stops publishing but TCP stays alive" quirk at `bambu_mqtt.py:3181`), so the probe falsely rejected a printer that would have connected fine. Second, the `finally: client.disconnect()` call ran synchronously on the asyncio thread — `BambuMQTTClient.disconnect()` ends in paho's `loop_stop()` which `join()`s the network thread, and if that thread was still mid-TLS-handshake to the slow P1S socket when teardown ran, the `join()` blocked the asyncio thread for as long as the handshake took to either complete or fail. POST `/printers/` therefore wedged, all other HTTP requests queued behind it, and Docker healthcheck timed out → user-visible symptom: "the container hangs." Reporter's workaround (downgrade to 0.2.4.1, add P1S, upgrade back) worked because 0.2.4.1's create-printer route skipped the probe entirely, so the row persisted immediately and the slow handshake happened on a fire-and-forget `connect_printer()` in the background. **Fix** swaps the fixed-sleep + sync-disconnect pair for a polling loop with an 8s budget (`PROBE_TIMEOUT_SECONDS`, configurable as class attributes for tests) that early-returns the moment `state.connected` flips True — so happy-path connects still finish in ~1–2s and slow brokers get the headroom they need — and moves `client.disconnect()` to `await asyncio.to_thread(client.disconnect)` so paho's thread-join can never block the event loop. The new `connect_printer` from-existing-row flow that runs after a successful probe is unchanged (still fire-and-forget). The empty-card-report-prevention goal of the original probe stays intact: a genuinely wrong access code still results in `connected=False` after 8s of polling, the 400 with `code=printer_connection_failed` still fires, the row is still never persisted. **Tests** (2 new in `test_printer_manager.py`): `test_test_connection_polls_and_returns_early_on_connect` simulates the P1S timing — `connected=False` at probe start, flips True ~500ms in — and asserts the probe early-returns in under 1.5s with `success=True` (a regression that reverts to the fixed sleep fails this immediately); `test_test_connection_disconnect_runs_off_loop` mocks a deliberately-slow blocking disconnect (mirrors paho's `loop_stop()` join semantics) and asserts (a) `disconnect` ran on a thread other than the asyncio thread, and (b) a concurrent heartbeat coroutine kept ticking while disconnect was blocking the worker thread, proving the event loop wasn't stalled. The existing `test_test_connection_failure` test was patched to override `PROBE_TIMEOUT_SECONDS` to 0.4s so the negative path still runs fast under CI. 6 printer-create integration tests still green; ruff clean. - **Stats: Failure Analysis widget no longer shows "Unknown" for archives classified after the fact (#1444, reported and root-caused by @needo37)** — Reporter spotted that the Stats page "Top Failure Reasons" widget grouped failed prints as `Unknown` even after they'd been classified via the Edit Archive modal. He went through the data layer and identified the desync: two `failure_reason` columns exist — `print_archives.failure_reason` written by `PATCH /archives/{id}` and `print_log_entries.failure_reason` read by the widget (`backend/app/services/failure_analysis.py:88`). `PrintLogEntry.failure_reason` gets captured exactly once at print-completion time (`backend/app/main.py:3641`) by copying `archive.failure_reason` — and at that moment the archive value is still `NULL` because the user hasn't picked a reason yet. The Edit Archive modal's PATCH route then writes only to `print_archives` via a generic `setattr` loop, never touching the log entry → widget stays stuck on `Unknown` forever. The reporter confirmed the desync at the DB level (`archive.failure_reason = 'Adhesion failure'`, `print_log_entry.failure_reason = NULL`). Fix mirrors `failure_reason` and `status` from the PATCH payload to the most recent `PrintLogEntry` for that archive (highest `id`). Latest-only because `archive.failure_reason` / `status` already reflect the *latest* run's outcome (each reprint clears the archive's reason at `main.py:2195` and rewrites it at completion), so the Edit Archive modal is implicitly showing — and editing — the latest run; reprints of an archive that succeeded on the second attempt keep the original failed run's classification intact. Scoped to those two fields only — `cost`, `print_name`, `printer_id` etc are deliberately *not* mirrored because per-run values legitimately diverge from archive-level ones (e.g. partial-print cost on a failed run differs from the source archive's full-print cost, see `_compute_run_filament_grams` at `main.py:596`). **Tests** (3 new in `test_archives_api.py`): the bug repro (failure_reason mirrors), the status case (the second field the reporter flagged), and the reprint guard (only the latest of multiple entries gets touched, an earlier entry keeps its prior reason). 55 archives-API tests green; ruff clean. - **SpoolBuddy: spool ID surfaced everywhere a spool's identity is rendered + Write-Tag page honours Spoolman mode (#1439, reported + partially prototyped by @flom89)** — Reporter buys filament in bulk and registers every individual spool in Spoolman at intake time (each gets a unique ID + a printed barcode that goes onto the physical roll when it's unboxed). When linking an NFC tag to one of those rolls in SpoolBuddy, the picker showed only material + colour + brand — so for ten identical "Black PLA" rolls every row looked the same. The user had no way to tell which physical spool they were about to bind the tag to; the original #1385 fix had surfaced the ID in Bambuddy's main UI (SpoolFormModal, FilamentHoverCard, the inventory-mode LinkSpoolModal) but the parallel SpoolBuddy components had been missed — they ship as part of the Bambuddy frontend repo under `frontend/src/components/spoolbuddy/` and `frontend/src/pages/spoolbuddy/`, not as a separate codebase. **Part 1 — ID surface, seven spots**: `#` in muted small monospace added to `LinkSpoolModal.tsx` (the link-tag-to-spool picker — the reporter's primary use case), `SpoolBuddyWriteTagPage.tsx` (write-tag picker — reporter's second screenshot), `AssignToAmsModal.tsx` header (single-spool context but disambiguating IDs help confirm the right roll was picked), `TagDetectedModal.tsx` (defined but unmounted today — kept consistent for future use), `SpoolInfoCard.tsx` (the found-tag panel on the right side of the dashboard — the "main screen" view), `InventorySpoolInfoCard.tsx` (matching inventory variant), and `SpoolBuddyAmsPage.tsx` AMS-slot assigned-spool block. All seven placements mirror the #1385 pattern (`#` with `shrink-0` so truncation never hides the ID). Frontend-only — the ID was already on the Spool / InventorySpool API shape (`spool.id`); these seven files just weren't surfacing it. **Part 2 — Spoolman-mode parity on the Write-Tag page**: reporter then surfaced that the same page hardcoded `api.getSpools(false)` regardless of inventory backend — so users in Spoolman mode (whose authoritative inventory is at Spoolman, not the internal table) saw spools they never created, and a successful tag write would bind the NFC tag to the wrong backend (the backend `/spoolbuddy/nfc/write-tag` route is mode-aware via `_get_spoolman_client_or_none`, but the frontend was driving it with internal-mode IDs that don't exist on the Spoolman side). Fix follows the wrapper pattern InventoryPage uses (`InventoryPageRouter` at `:445`): page detects `spoolmanMode` from a `getSpoolmanSettings` query at the top and threads it through, with `enabled: spoolmanModeReady` gating the spool fetch until settings load so we don't burn a wrong-backend request during the initial render. Every API call in the page now branches on `spoolmanMode` — 6 sites: the main spool list, the NewSpoolTouchForm's autocomplete spool list, the untag flow (`linkTagToSpool` vs `linkTagToSpoolmanSpool` — the Spoolman variant doesn't accept `data_origin` since Spoolman manages that), the K-profile save (`saveSpoolKProfiles` vs `saveSpoolmanKProfiles`), single-spool create (`createSpool` vs `createSpoolmanInventorySpool`), and bulk create (`bulkCreateSpools` vs `bulkCreateSpoolmanInventorySpools` — the Spoolman variant returns a `SpoolmanBulkCreateResult` envelope vs raw array, handled with a duck-typed `'created' in result` check that mirrors `SpoolFormModal`'s existing pattern). Same shape rule as [[feedback_sqlite_and_postgres_upfront]] / [[feedback_inventory_modes_parity]]: both modes ship in the same drop, no `spoolmanMode ? undefined : ...` UI gates. **Tests**: 3 new in `SpoolBuddyWriteTagPage.test.tsx` — the ID-visibility regression with two identical PLA-Red rolls IDs 42 / 43 (a future refactor that drops the ID span breaks it), plus two parity regressions (`reads from internal inventory when Spoolman mode is OFF` and `reads from Spoolman when Spoolman mode is ON` — the latter asserts `getSpools` is NOT called when the user is in Spoolman mode, so re-hardcoding the internal endpoint breaks CI immediately). 11 WriteTagPage tests + 51 other SpoolBuddy component tests green (62 total); frontend build clean. - **FTP: P2S upload truncates / 426 "Failure reading network stream" on Python 3.13 (#1401, reported and root-caused by @iitazz)** — Reporter on a P2S running firmware 01.02.00.00 saw every Bambuddy-initiated print fail with the printer's on-screen "unable to parse 3mf file" error ~30 s in; downloading the file back off the printer's SD card confirmed it was truncated at exactly 7 × 64 KB (clean chunk-boundary cut). Initial #1417 follow-up tightened our 426 handling so we'd surface upload failures instead of silently dispatching a print of a partial 3MF — but that only stopped Bambuddy from hiding the problem; the actual upload still failed. The reporter then dug into it with Gemini and identified the real cause: Python 3.13's default `ssl.create_default_context()` negotiates TLS 1.3 when both peers support it, but the printer's vsFTPd build implements session reuse on the FTPS data channel against an old OpenSSL that doesn't tolerate TLS 1.3's asynchronous session-ticket model. The control-channel handshake completes, the data channel tries to resume the session, the resumption races, the data channel gets torn down mid-stream — first ~448 KB of bytes already in the TCP buffer land on the SD card, the rest never make it, printer's vsFTPd replies 426 instead of 226. **Fix** caps the SSL context's `maximum_version` to TLS 1.2 so session resumption is synchronous and the upload completes normally. Implementation follows the pattern just established by `camera_profiles.py` in the #1395 follow-up: a new `backend/app/services/ftp_profiles.py` module with an `FTPProfile` frozen dataclass (one field today, `cap_tls_v1_2: bool = False`) and a per-model registry. Default profile keeps the historical TLS-1.3 negotiation; P2S (display name + internal SSDP code N7) overrides with `cap_tls_v1_2=True`. `ImplicitFTP_TLS.__init__` gains a matching `cap_tls_v1_2` kwarg; `BambuFTPClient.connect()` looks up the profile and threads the flag through. **Deliberately scoped to P2S only** — X1C / P1S / H2D installs that work today stay on the negotiated TLS 1.3; flipping a future model to the capped path is a one-line entry in `_PROFILES` when a new reporter surfaces the same symptom. Considered but rejected the reporter's second proposed change (revert manual `transfercmd` + `sendall` back to `storbinary`) — the stated rationale ("raw sendall breaks OpenSSL 3.x framing") is incorrect (CPython's `storbinary` itself uses `sendall` internally; the actual socket-level behaviour is identical), the move to manual `transfercmd` was deliberate to dodge A1 hanging in `storbinary`'s synchronous `voidresp()`, and the #1417 SIZE-check escape for the "data is intact on the SD card despite the 426" race lives in the manual-transfer path — a switch to `storbinary` would lose that protection. **Tests**: 9 new in `test_ftp_profiles.py` (default profile doesn't cap; unknown / empty model falls back; P2S display name and N7 SSDP code both resolve to capped; lookup is case-insensitive; X1C / H2D / P1S / A1 stay uncapped; dataclass is frozen; **integration test pins the wiring** — `ImplicitFTP_TLS(cap_tls_v1_2=True)` actually sets `ssl_context.maximum_version == TLSVersion.TLSv1_2`, guards against a future refactor that drops the profile→context wiring while keeping the registry looking correct). 87 existing `test_bambu_ftp.py` tests still green; ruff clean. - **Library 3D preview: complex multi-part 3MFs no longer freeze the page (#1412, reported by @anthonyma94)** — Reporter opened the 3D preview on a multi-color parted MakerWorld statue ("Mecha Mewtwo No AMS Multi Color Parted Statue") and the whole Bambuddy UI locked up — modal close button unresponsive, had to kill the tab. Root cause was in `frontend/src/components/ModelViewer.tsx`: the 3MF parse runs entirely on the browser main thread (JSZip extract + DOMParser + `getElementsByTagName('vertex')` / `('triangle')` iteration + `mergeGeometries`), with no yield points between iterations. Bambu Studio's external-component shape (`` per part) compounds this — each component triggers another async file extract + DOM parse + vertex/triangle loop, all chained without surrendering control to the event loop between phases. For trivial models (the towel hook and Bambu scraper the reporter cited as working) the total wall-clock is short enough that the freeze isn't visible; for parted statues with dozens of components and high-poly meshes, the main thread is pegged for tens of seconds → browser shows "page unresponsive" and the close button can't fire. **Stopgap that shipped here** adds explicit `nextTick()` yields (`await new Promise(r => setTimeout(r, 0))`) at four hot spots: every 20 000 vertex iterations, every 20 000 triangle iterations, once per top-level `` iteration, and once per `` iteration. Parse wall-clock is unchanged — these yields don't make parsing faster, they just surrender the main thread back to the browser between batches so the modal can be closed, the page can scroll, and the loading spinner can actually render. Constants live next to the helper at the top of the file with a comment justifying the picked period (~5–10 ms of work per batch — fine-grained enough to keep frames flowing, coarse enough not to drown the loop in setTimeout dispatch overhead). The proper fix for this — moving 3MF parse + geometry build into a Web Worker so the main thread is never touched at all — is a tracked follow-up; this stopgap unblocks Anthony's reproduction case today without the worker refactor risk. The earlier close as `invalid` was a misdiagnosis (initial reading was that 3D preview only works on sliced files, which the reporter correctly disproved with a separate MakerWorld URL); reopened, fixed, lesson noted. **Tests:** 21 existing `ModelViewerModal.test.tsx` tests stay green — the yields are in `parseMeshFromDoc` and `parse3MF` which the tests mock around, and the `nextTick` helper has no observable side effects beyond timing. Frontend build clean. - **Archives: timelapse auto-attach now works for VP-queue / dispatch prints (#1403 follow-up, reported by @pwostran)** — Bambuddy uses a snapshot-diff strategy to pick the right MP4 off the printer's SD card after a print (Bambu printers in LAN-only mode don't sync NTP, so file mtimes are unreliable — `_scan_for_timelapse_with_retries` snapshots existing video filenames at print start and looks for any NEW filename at completion). The baseline-capture call was inline at the bottom of `on_print_start`'s **new-archive branch only** — the **expected-archive branch** (which queue / VP-dispatched / reprinted jobs take, anything registered via `register_expected_print`) exited at its own `return` without ever snapshotting. So queue prints had `_timelapse_baselines[printer_id]` unset; the completion-time scan fell into its "take baseline now" fallback that snapshots the SD card *after* the new MP4 has already landed → the new file sits in the "baseline" set → no diff ever matches → auto-attach silently does nothing. The reporter's `bambuddy-support-20260518-185935.zip` shows the failure verbatim: `Using expected archive 3 for print (skipping duplicate)` at `18:41:10`, `Timelapse was active during print, scheduling auto-scan for archive 3` at `18:58:55`, and `[TIMELAPSE] Archive 3 has no printer, aborting` (a separate bug already fixed by the printer_id-assignment commit in this same train) — and `grep -i baseline` across both his bundles returns zero hits, confirming the snapshot never ran. Fix extracts the inline baseline-capture into `_capture_timelapse_baseline_at_start(printer, printer_id, logger)` and calls it from BOTH branches of `on_print_start` (mirroring the existing site in the new-archive branch with a matching call just before the expected-archive branch's `return`). Helper is best-effort with a `try / except Exception` wrapping `_list_timelapse_videos`, so a transient FTP failure at print-start logs `[TIMELAPSE] Failed to capture baseline at print start: …` and the print proceeds — the completion-time fallback still kicks in (with its known limitation), behaviour matching what the new-archive branch had all along. **Tests:** new `test_expected_archive_path_captures_timelapse_baseline` in the existing `test_print_start_assigns_printer_id_to_vp_archive.py` patches `_list_timelapse_videos` to return two pre-existing videos, runs `on_print_start` through the expected-archive branch, and asserts `_timelapse_baselines[1] == {"earlier_print_a.mp4", "earlier_print_b.mp4"}` — a future refactor that removes the call from one of the branches now fails CI. The existing 2 regressions (`test_expected_archive_path_assigns_printer_id_when_unset`, `test_expected_archive_path_preserves_existing_printer_id`) plus 50 adjacent expected-archive / layer-timelapse / archive-filtering tests still green. The fixture clearing `_expected_prints` etc also clears `_timelapse_baselines` now so test isolation holds. ## [0.2.4.1] - 2026-05-16 ### Added - **Docker: opt-in system trust store for self-signed CA certificates (#1431, contributed by @WizBangCrash, requested in #1289)** — Reporter runs a private LAN with self-signed certificates for internal HTTPS endpoints (his Home Assistant instance being the canonical case) and wanted Bambuddy to trust those CAs without disabling TLS verification end-to-end. Bambuddy talks to Home Assistant via `httpx.AsyncClient` (`backend/app/services/homeassistant.py:46`) with default `verify=True`, which under httpx 0.28 means "use `certifi`'s CA bundle and nothing else" — so manually copying a CA file into the container had no effect. **The fix is opt-in and container-side only**: setting `USE_SYSTEM_TRUST_STORE=` in the compose `environment:` block, combined with mounting the user's CA file(s) into `/usr/local/share/ca-certificates`, makes the entrypoint run `update-ca-certificates --fresh` at startup and `export SSL_CERT_DIR=/etc/ssl/certs`. httpx 0.28 explicitly honours that env var (`_config.py`: `ssl.create_default_context(capath=os.environ["SSL_CERT_DIR"])`), and `update-ca-certificates` populates `/etc/ssl/certs` with the **Debian system CA bundle** (Let's Encrypt, DigiCert, GlobalSign, etc.) **plus** the user-mounted CAs — so standard endpoints (api.github.com, MakerWorld, Bambu Cloud) keep working alongside the user's self-signed CA. The `ca-certificates` apt package is added to the Dockerfile so `update-ca-certificates` exists in the image. The feature is **default-off** — when the env var is unset the entrypoint logs a one-line "skipping system trust store update" and goes straight to the existing PUID/PGID chown path, so non-users see zero behaviour change. **Fail-fast on misconfig**: if `USE_SYSTEM_TRUST_STORE` is set but the container is running as non-root (the entrypoint can't write `/etc/ssl/certs` without root), or `/usr/local/share/ca-certificates` has no `.crt` files mounted, or `update-ca-certificates` is missing from the image, or the trust-store rebuild itself fails, the entrypoint exits 1 with a clear error message rather than silently succeeding and leaving the user wondering why their HA connection still rejects the cert. **Compose template update**: `docker-compose.yml` ships commented-out examples for both the volume mount (`/path/to/certs:/usr/local/share/ca-certificates`) and the env var (`USE_SYSTEM_TRUST_STORE=true`) so the path from "I have a self-signed CA" to "Bambuddy trusts it" is two uncommented lines. **Caveat worth flagging in docs**: the feature requires the container to start as root so the entrypoint can run `update-ca-certificates`; users who pin `user: "1000:1000"` in compose get the clear "not running as root" exit with the reason, but they need to switch to the default PUID/PGID-style invocation to use this. Companion wiki PR documents the setup walkthrough at maziggy/bambuddy-wiki#31. Hardware-only path (shell entrypoint change) so no automated test — verified by the reporter's local install. Post-merge polish: the fatal-exit branch's log line was relabeled from "warning: update-ca-certificates failed:" to "error: update-ca-certificates failed" to match severity and the surrounding error messages. - **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. ### Changed - **Inventory: AMS Filament Label Holder presets fixed and split into "small" and "large" variants (#1426, reported by @bsaunder)** — Reporter (the same person who originally requested the labels feature in #809) discovered that the `ams_30x15` preset's 30×15 mm dimension didn't fit any documented variant of MakerWorld model 752566, despite the preset advertising itself as designed for it. Two new presets replace it: `ams_holder_74x33` (matches the printable label STL bundled in the project) and `ams_holder_75x55` (fits the cardstock-insert variant the reporter validated as "fits perfectly"). Both land in the roomy-layout branch (swatch + QR + multi-line text with brand / material / hex / spool ID) because the height crosses the 20 mm threshold — so the larger AMS holder labels carry the QR code back to `/inventory?spool=` that the old 30×15 preset couldn't fit. The 30×15 preset is removed entirely; no DB migration needed because the preset name was never persisted (label printing is a one-shot action and the picker defaults to nothing). The legacy tight-layout code path in `_draw_label_tight` is kept as the safety branch for any future ultra-small preset (no shipped template uses it now). **API change**: `POST /inventory/labels` and `POST /spoolman/labels` accept `template` values `ams_holder_74x33` and `ams_holder_75x55` instead of `ams_30x15`. The Literal types in `backend/app/api/routes/labels.py` and `frontend/src/api/client.ts::SpoolLabelTemplate` reject the old value at validation time, so any caller still scripting `ams_30x15` gets a 422 with a clear "valid values" message. i18n: replaced `inventory.labels.templates.ams.{label,hint}` with `inventory.labels.templates.amsHolderSmall.{label,hint}` and `inventory.labels.templates.amsHolderLarge.{label,hint}` — real translations across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW), the English-fallback strings on the old key are gone (`feedback_translate_dont_fallback` rule). Parity check passes at 4856 leaves per locale. Tests: `test_label_renderer.py::ALL_TEMPLATES` updated and the AMS-specific render test pins the small variant; `test_labels.py` integration test covers both new presets end-to-end; `LabelTemplatePickerModal.test.tsx` updated to expect 6 template buttons in the grid (was 5), pin the new `ams_holder_75x55` template value in the API call assertion, and verify both AMS variants are disabled when no spools are selected. 35 affected backend tests + 17 frontend modal tests green; full suite 5035 green; ruff clean; frontend build clean. ### Fixed - **Archives: "Scan for timelapse" no longer permanently disabled on VP-queue-dispatched prints (#1403 follow-up, reported by @pwostran and @enjoylifenow)** — Reporters dispatched a print from a slicer via the VP print queue, the printer recorded the timelapse to its SD card (visible and downloadable via Bambuddy's file browser), but the archive UI's "Scan for timelapse" and "View Timelapse" actions stayed greyed out forever. Frontend gates both on `archive.printer_id` (`ArchivesPage.tsx:459`); backend `/archives/{id}/timelapse/scan` also 400s when the archive has no `printer_id`. Root cause was in `main.py::on_print_start`'s expected-archive branch: VP-queue archives are created with `printer_id=None` at queue-add time (we don't yet know which printer will run the job — the scheduler decides later for "Any P1S"-style queue items, and even for explicit-printer queue items the archive is created before dispatch). When the print actually starts and `on_print_start` looks up the expected archive via `_expected_prints`, the branch updated `status`, `started_at`, and `subtask_id` but never assigned `printer_id`. So the archive stayed `printer_id=None` for the entire print and forever after — and every downstream UI / API path gated on it (timelapse scan + view, the printer filter on Archives, per-printer stats, success-rate cohort attribution) treated the archive as "unassigned." One-line fix in the expected-archive branch sets `archive.printer_id = printer_id` when they differ, guarded against clobbering an already-correct value so library-file-based queue items (which create their archive with the printer pre-assigned at dispatch time) are unaffected. Two regressions in a new `test_print_start_assigns_printer_id_to_vp_archive.py`: `test_expected_archive_path_assigns_printer_id_when_unset` (VP-queue archive with `printer_id=None` → promoted to the running printer) and `test_expected_archive_path_preserves_existing_printer_id` (library-file archive with `printer_id=7` stays at 7 when the same printer runs it — the branch is idempotent on correct data). The 2 existing `test_layer_timelapse_expected_archive.py` tests + 19 `test_print_start_expected_promotion.py` regressions still green — the expected-archive branch's other side-effects (timelapse session, AMS mapping, status/started_at promotion) are unchanged. 5033 backend tests + ruff clean. **Note about the queue-mode slicer-inheritance path itself**: pwostran's specific complaint that the original #1403 fix "didn't work" was a misdiagnosis on his end — his support bundle proves Bambuddy correctly sent `timelapse: true` to the printer and the printer recorded the video; his actual gap was this archive-attachment bug. The slicer-inheritance branch added in #1403 is a no-op for OrcaSlicer's "Send to print queue" flow because Orca only does the FTP upload and never sends a `project_file` MQTT command at upload time — so the path falls back to `default_*` settings, which is the operative path for that workflow. Users who want a different per-print value still edit the queue item before starting (as pwostran did, which is why his dispatch carried `timelapse: true`). - **SpoolBuddy: NFC reader works again on Raspberry Pi 5 (#1424, reported by @flom89)** — Reporter on a Pi 5 installed SpoolBuddy successfully but couldn't talk to the PN5180 NFC module (the gauge worked, so SPI hardware and wiring were fine). Manually commenting out `self._spi.no_cs = True` in the daemon restored communication; reporter wasn't sure whether removing it would regress Pi 4 installs. Root cause: Pi 5 uses the new RP1 southbridge and its kernel SPI driver (`spi-rp1`) doesn't accept the `SPI_NO_CS` ioctl the same way the historical Broadcom driver on Pi 4 did — setting `no_cs = True` on Pi 5 either errors out or silently leaves the bus in a state where transfers don't complete. **Safe to drop Pi-wide, not just Pi 5** — SpoolBuddy's PN5180 NSS line is wired to GPIO23 (manual chip-select handled by `_cs_low()` / `_cs_high()` around every transfer, because the kernel's default 5µs setup / 100µs hold timing doesn't meet the PN5180's spec). The hardware CE0 line (GPIO8) is not connected to the reader, so whether the kernel auto-toggles it during `xfer2()` is electrically invisible to the PN5180. The `no_cs = True` call was a "be polite to the bus" gesture that was always cosmetic on this hardware. Fix wraps the assignment in `try / except OSError` in both `spoolbuddy/daemon/pn5180.py` (logs at debug level) and `spoolbuddy/scripts/read_tag.py` (silent — it's a diagnostic script with no logger). Try/except over a hard delete because Pi 4 installs that work today shouldn't see any behaviour change. README updated at `spoolbuddy/README.md:23-28` to drop the "spidev.no_cs = True resolves this" sentence in favour of explaining that manual CS via GPIO23 carries the timing on its own and that Pi 4 + Pi 5 are both supported. Hardware-only path so no automated test — verified by the reporter's bench test that commenting the line out restores reads. Ruff clean. - **Cover thumbnails: stop hammering FTP and GitHub when a print's 3MF isn't on the printer (#1420, reported by reporter)** — Reporter on a P2S running 0.2.4.1 saw two log-flooding bugs trigger together once they started a print whose 3MF wasn't on the printer's FTP storage (typical SD-card-only print). **(1) Cover endpoint had no negative cache.** `GET /printers/{id}/cover` cached successful 3MF thumbnail downloads in `_cover_cache` keyed by `(subtask_name, view_key)`, but never recorded failures. When all 8 candidate FTP paths returned `550 Failed to open file`, the endpoint raised 404 without remembering that it just tried — and since `cover_url` stays populated on every `PrinterStatus` response while state is RUNNING/PAUSE, every React-Query refetch and every component remount drove the frontend to re-fetch, replaying the same 8-path FTP fan-out roughly every few seconds. On the user's hardware the printer's single FTP socket was so busy with these doomed retries that it surfaced as camera-stream symptoms ("ffmpeg didn't terminate gracefully"). Fix adds a parallel `_cover_404_cache: dict[int, set[tuple[str, str]]]` that records the same `(subtask_name, view_key)` key on every 404 path — both the all-FTP-paths-failed branch and the 3MF-has-no-thumbnail-inside branch. On the next call for the same key, the endpoint short-circuits to 404 before even consulting FTP. The negative cache is cleared in `clear_cover_cache()` alongside the positive cache, which `main.py::on_print_start` already calls — so when the next print starts (different subtask, or same subtask after a re-upload of a new file) Bambuddy retries fresh. **(2) GitHub update-check had no backoff on 403 rate-limit.** Once `api.github.com` returned `403 rate limit exceeded` (typical when multiple Bambuddy instances or other tools share a NAT'd source IP and exhaust the unauthenticated 60-req/hr quota), the next call hit GitHub again immediately. Fix adds module-level `_github_rate_limit_until` epoch-seconds plus three helpers in `updates.py`: `_seconds_until_github_unblocked()`, `_record_github_rate_limit(response)` (reads `X-RateLimit-Reset` from the 403, falls back to a 1-hour pause when the header is absent or unparseable, and only extends the window — never shortens it via an out-of-order response), and `_is_github_rate_limit_response(response)` (status 403/429 with `X-RateLimit-Remaining: 0`, body-text fallback when proxies strip the header). Both call sites — `GET /updates/check` and the in-app updater's `_discover_target_release` — short-circuit when the window is active; the route surfaces a structured `{error: "GitHub rate limit reached...", retry_after_seconds: }` response so the SettingsPage UI can show a real wait time instead of an opaque "failed to check for updates". The "ffmpeg didn't terminate gracefully" warning line the reporter quoted is the standard SIGTERM → 2s wait → SIGKILL pattern in `camera.py::_terminate_ffmpeg` — RTSP/TLS streams routinely take >2s to drain and that warning fires for many users with no FTP issues; once the cover loop is silenced the resource pressure is gone, and the warning itself is cosmetic. **Tests**: `test_cover_negative_cache_skips_repeat_ftp_fanout` in `test_printers_api.py` mocks `download_file_try_paths_async` to return False, calls the endpoint twice, and asserts the second call's FTP mock count is unchanged (the negative cache held); `test_check_backs_off_after_github_rate_limit` in `test_updates_api.py` patches `httpx.AsyncClient` to return a 403 with `X-RateLimit-Reset` set 10 minutes ahead and asserts the second `/updates/check` request never reaches httpx and surfaces `retry_after_seconds > 0`. 134 printers + updates integration tests green; ruff clean. - **Assign Spool: printer card refreshes immediately, no Force-refresh needed (#1414 follow-up, reported by @snozzlebert)** — After assigning a spool via the modal, the Filament page Location column updated correctly but the Printer card kept showing "Empty slot (External Slot 1)" until the user manually pressed Force-refresh. The MQTT command itself was going through fine; the gap was on the client side. `AssignSpoolModal`'s two `useMutation.onSuccess` callbacks invalidated the inventory / slot-assignment queries (Filament page reads from those — correct) but never invalidated `['printerStatus', printerId]` and never issued a `pushall` to make the printer republish its state. For Bambu RFID-tagged spools the printer echoes the new `tray_type` over MQTT on its own and the websocket push would eventually surface it, but for non-RFID spools and A1 mini external slots (reporter's case) the firmware doesn't volunteer that state change, so the card sat on stale `tray_type: ""` and `getEmptySlotKind()` rendered the "Empty slot" path. Fix adds a `nudgePrinterRepublish()` helper called from both `onSuccess` paths (internal-inventory `assignMutation` and `assignSpoolmanMutation`): calls `api.refreshPrinterStatus(printerId)` to issue the pushall (same call the Force-refresh button uses at `PrintersPage.tsx:1844`) and invalidates `['printerStatus', printerId]` so the refetch lands. Failures from `refreshPrinterStatus` are deliberately swallowed — the assignment itself already succeeded, and if the refresh nudge is offline the next regular poll / websocket update will catch up; we don't want to surface a misleading "assign failed" toast for a stale-cache cleanup that didn't go through. Mirrors the pattern `ConfigureAmsSlotModal` has used since #1235 (line 5346) but with the extra pushall step because assign-spool affects firmware-side state where configure-slot affects only client-side preset mapping. Same fix covers both inventory modes thanks to the shared helper. **Tests:** new `nudges the printer to republish after successful assignment (#1414)` in `AssignSpoolModal.test.tsx` — picks a material-matching spool to bypass the mismatch-confirm dialog, clicks the Polymaker spool card to select it, clicks "Assign Spool", asserts `api.refreshPrinterStatus(7)` was called. `printerId=7` (not the default 1) verifies the helper threads the prop value through correctly rather than hardcoding. The api mock at the top of the file gains `assignSpoolmanSlot`, `getSpoolmanSlotAssignments`, and `refreshPrinterStatus` so the existing 13 tests still pass alongside the new one. 14 modal tests + build clean. - **FTP: tolerate transient 426 from buggy printer FTP when the file is actually on the SD card (#1417 follow-up, reported by @enjoylifenow)** — In the previous daily build, commit `1fac0276` tightened the post-STOR confirmation handler in `bambu_ftp.py` so that any `ftplib.Error` from `voidresp()` (including `error_temp 426` "Failure reading network stream") would fail the upload outright. The goal was to stop Bambuddy from sending a print command for a truncated 3MF when the printer's FTP server explicitly told us the data stream was cut — exactly the scenario surfacing the user's earlier "unable to parse 3mf file" 30 s into a print. Reporter then confirmed (after running a filesystem check + reformat + power cycle, all clean) that the same install **worked fine on v0.2.4.1** — proving that for the specific P2S firmware revision in question, the 426 is *noise*: the TLS data-channel close races the 226 confirmation, the server reports failure on voidresp, but the file *did* land fully on the SD card. The previous proceed-with-warning behaviour was accidentally correct for that firmware quirk. Reverting wholesale would re-introduce the silent-truncation bug, so instead narrow the rule: when voidresp raises an `ftplib.Error`, immediately follow up with an FTP `SIZE` query against the freshly-uploaded path. If the server-side size matches what Bambuddy just sent, the file is provably intact and Bambuddy proceeds with a warning (`FTP STOR returned error_temp for X but file is intact on the printer (N bytes match) — proceeding`). If the size doesn't match — or `SIZE` itself raises — the transfer was genuinely truncated (or the server is in too broken a state to be trusted) and the upload fails loudly with the error log path the previous round added (`server size=... expected=...`). Same logic is applied to both `upload_file()` and `upload_bytes()` so the legacy A1-compatibility manual-transfer path is covered identically. **Tests:** the two existing regressions from the previous round (`test_upload_426_data_stream_failure_returns_false`, `test_upload_bytes_426_data_stream_failure_returns_false`) are renamed and split: `test_upload_426_with_intact_file_proceeds` (SIZE matches → returns True, the reporter's case), `test_upload_426_with_truncated_file_returns_false` (SIZE smaller than expected → still fails, the original bug we don't want to regress), `test_upload_426_with_size_check_failing_returns_false` (SIZE itself raises → assume the worst), plus parallel coverage for `upload_bytes()`. The intact-file tests have to inject `SIZE` explicitly because the pyftpdlib mock only flushes the on-disk file after a clean voidresp — which doesn't happen when we monkeypatch voidresp to raise — and the docstring spells that out for future readers. 87 FTP unit tests green; ruff clean. The View-Timelapse-greyed-out behaviour the original #1417 report flagged stays untouched here — once the reporter confirms their upload reliability is back, that diagnosis continues on a healthy install. - **AMS: physically-empty slots now consistently report state=9 (#1322 follow-up, diagnosed by @RosdasHH)** — Reporter dug into the BambuStudio source and pointed out that our previous fix only caught the narrow `{"id": N}` bare-payload shape, which Bambu firmware only sends right after a printer restart. In steady-state operation — including the more common post-Reset-Slot path on P1S and the A1 Mini BMCU — firmware sends a populated payload with stale fields and signals emptiness via the `tray_exist_bits` bitmask instead. Bambuddy already parsed that bitmask at `bambu_mqtt.py:1758` (`slot_exists = (tray_exist_bits >> global_bit) & 1`) and used it to wipe stale `tray_type` / `tray_color` / `tag_uid` fields, but never promoted the slot's `state`. So downstream readers — the API serializer at `printers.py:457`, the `tray_state in {9, 10}` short-circuit in `inventory.py:1358`, the AMS card — all saw `state: null` and had to guess from absent payload fields. Reporter's API screenshot showed exactly that shape: `state: null, tray_color: null, remain: 0, ...`. Fix lifts a `tray["state"] = 9` assignment to the outer `if not slot_exists` branch (was nested inside the stale-data-clear branch), so the bitmask path now writes the canonical "no spool" state for every empty slot regardless of whether stale fields are present. Hard-typed as `int` 9, not string `"9"` — the downstream check at `inventory.py:1358` uses `tray_state == 9` (not `in {"9", 9}`), so a string would have silently missed and the reporter's deadlock would have come right back. The previous narrow heuristic in `printer_manager.py:797-798` (the `len(tray) == 1 and "id" in tray` shape detector) stays in place as belt-and-suspenders for the post-restart bare-payload edge that bypasses the AMS merge — costs nothing and protects against any MQTT path that doesn't flow through `_handle_ams_data`. The `state: null` surfacing on the API resolves automatically since `printers.py` reads `tray_data.get("state")` directly. **Tests:** two new in `test_bambu_mqtt.py::TestAMSDataHandling` — `test_tray_exist_bits_promotes_empty_slot_to_state_9` exercises the steady-state populated-payload path (slot occupied → bitmask flips bit 1 to 0 → state=9, type-asserted as int; loaded sibling slot keeps its state=11 unchanged); `test_tray_exist_bits_does_not_change_state_on_loaded_slots` pins the negative path (bitmask bit=1 with state=3 leaves state untouched — transitional firmware states like "unloading" don't get corrupted). The two `printer_manager.py` regression tests for the narrow heuristic (`test_bare_tray_emulates_state_9`, `test_populated_payload_with_empty_state_3_is_not_promoted`) stay green — that path is unchanged. 397 mqtt+printer-manager unit tests + 50 inventory/Spoolman slot-assignment integration tests = 447 affected tests green; ruff clean. **UI: visual distinction between physically empty and unconfigured slots (in the same drop).** With the data layer now consistent, the AMS slot card surfaces what Bambuddy actually knows about each empty slot, without overclaiming. New helper `getEmptySlotKind(tray)` in `PrintersPage.tsx` returns `"physical"` (state ∈ {9, 10} — firmware positively confirmed no spool), `"reset"` (any other empty state — could be a user-cleared assignment, mid-unload, or just a slot the firmware hasn't reported on yet), or `null` (loaded). The inline label below the slot circle reads `t('ams.slotEmpty')` ("Empty") uniformly for any empty slot (regular AMS, HT, external) so users get a consistent label everywhere — the previous version only said "Empty" for firmware-confirmed state=9 slots and fell back to an em-dash otherwise, which surfaced as "Empty" for regular AMS slots but "—" for HT AMS (skipped by the bitmask loop) and external trays (separate MQTT path entirely). The state distinction now lives only on the **border** and **hover card** where it doesn't surprise. `FilamentSlotCircle` gains an `emptyKind` prop that picks a quieter dashed border colour for unconfigured slots (`#3d3d3d` vs `#666`), so the visual hierarchy reads "loaded > unconfigured > physically empty" at a glance even though the inline text only differentiates physical from everything else. `EmptySlotHoverCard` gains a `kind` prop and switches the hover label between `ams.emptySlot` ("Empty slot") for physical and the new `ams.emptySlotReset` ("No filament assigned") for everything else — also rewritten from the original "Slot reset — no spool assigned" for the same overclaim reason. All three slot-render call sites in `PrintersPage` (regular AMS grid, HT AMS single-slot, external spool tray) now compute and pass the kind. i18n: 2 new keys (`slotEmpty`, `emptySlotReset`) translated across all 8 locales; parity at 4854 leaves. New test `#1322: empty slot kind is "physical" when state=9 and "reset" otherwise` in `PrintersPage.test.tsx` reuses the existing `phase13EmptySlotProps` mock to capture the `kind` prop across a 4-slot fixture (state=9 / state=3 / state=null / loaded) and asserts each variant flows through. 71 PrintersPage + FilamentHoverCard tests green; build clean. - **Stats page: Filament Used, By Time, and Success Rate now agree with Total Consumed and Total Prints (#1390 follow-up, reported by @IndividualGhost1905)** — After the archived-spool fix shipped the reporter confirmed it worked and gently flagged the round Bambuddy had explicitly postponed: Quick Stats `Filament Used` / `Filament Cost` didn't match `Total Consumed` on the Inventory page; Printer Stats `By Time` didn't match Quick Stats `Print Time`; the success-rate gauge percentage didn't relate to the `Total Prints` count shown right above it. Three independent root causes, fixed together. **(1) Filament Used vs Total Consumed.** `_compute_run_filament_grams` in `main.py` short-circuited to the slicer estimate for `status == "completed"` even when inventory had measured the actual AMS weight delta — the comment on the old test literally said "the print is done, so the full estimate is the right answer." That made Stats and Inventory two different sources of truth: Stats showed slicer-estimate grams, Inventory showed AMS-tracked grams, and the two numbers naturally diverged (slicer estimates are typically a few percent off real consumption). Fixed by reordering the helper so the tracked spool delta (sum of `usage_results[].weight_used` — same source that drives the per-spool `weight_used` counter behind Total Consumed) takes priority for *every* status. The slicer estimate stays as the fallback when no inventory was tracked for the print, and the partial-progress scale stays as the fallback for failed/cancelled/stopped with no tracker — so the existing #1378 partial-aware behaviour is preserved. The `_run_cost` block right next to it already used this priority order, so cost was always tracker-first; only `filament_used_grams` was inconsistent. New prints now record what was actually consumed, so Stats and Inventory show identical numbers. **(2) Printer Stats By Time vs Quick Stats Print Time.** `/archives/slim` only populated `actual_time_seconds` when `status == "completed"`. For failed/cancelled rows the field stayed null and the frontend (`StatsPage.tsx::PrinterStatsWidget`) fell back to `print_time_seconds` — the slicer's *estimated full-print duration*, which is the wrong number for a print that failed at 15% progress. Quick Stats `total_print_time_hours` already counted every event's elapsed `duration_seconds` regardless of status, so the two halves of the page disagreed by the (estimate − actual-elapsed) gap on every non-completed event. Dropped the `status == "completed"` gate in the slim row's `actual_time_seconds` computation; failed/cancelled events now report their measured elapsed time and the frontend's `actual || print_time` fallback chain only ever falls through to the slicer estimate for events with no measured duration at all. **(3) Success Rate %.** Formula was `successful / (successful + failed)`, denominator excluding `cancelled` / `stopped` / any other status. Combined with the visible "Total Prints: N" label right above the gauge, that produced confusing numbers: 4 successful, 0 failed, 48 cancelled showed 100% out of an apparent 52 prints. Switched to `successful / total_prints` — straightforward "what fraction of all attempts succeeded", matches the count the user reads from the widget header. The widget's `stats` prop already exposed `total_prints` so no type changes were needed. **(4) Records widget "Longest Print" — knock-on from (2).** Before (2), `actual_time_seconds` was null for non-completed rows so the Records widget's `findMax(a => a.actual_time_seconds)` implicitly only ranked successful prints. Once (2) populated the field for failed/cancelled events too, an aborted 25-hour print would have outranked a genuinely successful 18-hour print as "Longest Print" — a real semantic regression. Added a `status === 'completed'` gate on the longest getter only, restoring the pre-fix semantic. The other two records (Heaviest Print, Most Expensive) already included non-completed events via `filament_used_grams` and `cost` and intentionally stay as-is, since those values are populated by the partial-progress / tracker logic in `_compute_run_filament_grams` and were never gated on status. **Out of scope** — backfilling the 52 historical events on the reporter's database: `PrintLogEntry.filament_used_grams` is already baked in as the slicer estimate for older prints and we don't store per-event AMS deltas separately to backfill from. The reporter said upfront she'd "reset all statistics and start over" to track new prints cleanly, so this lands without a migration. Similarly the Failure Analysis 30-day default (a separate divergence the agent surfaced while mapping the page) wasn't part of the reporter's complaints and stays untouched. **Tests:** `test_run_filament_helper.py::test_completed_returns_estimate_even_when_tracked_differs` was renamed and inverted to `test_completed_prefers_tracked_over_estimate` — it now pins the new contract (completed + tracker → tracker value), guarding against a future "trust the estimate again" refactor. All 13 existing helper tests still green; the helper's contract changed in exactly one place and the rest (no-tracker fallback, partial-progress scaling, multi-slot summation) is unchanged. `test_archives_api.py::test_slim_actual_time_null_for_failed` was renamed to `test_slim_actual_time_for_failed_includes_elapsed` and inverted — same pattern, the old assertion is now the regression check. `StatsPage.test.tsx` gains two: `uses total_prints as denominator so cancelled/stopped events count (#1390)` (40 successful / 20 failed / 40 cancelled-or-stopped = 40%, matches Total Prints: 100, where the old formula would have shown 67%); and `Longest Print excludes failed prints (#1390)` pinning that an aborted 25-hour run can't outrank a successful 8-hour print as the Longest Print record — protects against a future refactor that removes the new status gate inside `findMax`. 33 StatsPage tests + 66 archive-API + run-filament tests green; frontend build clean. - **Inventory: "Total Consumed" now includes archived spools' usage, and the eraser works on archived too (#1390 follow-up, reported by @IndividualGhost1905)** — After the original #1390 fix shipped, the reporter noticed that archiving a spool with consumed weight quietly subtracted that weight from the "Total Consumed" stat at the top of the Inventory page, and un-archiving put it back. Total Consumed is a *running* counter (lifetime usage since the last reset), not a current-inventory snapshot, so a spool's recorded prints SHOULD stay in the total even after the user archives the physical roll — otherwise the reset baseline becomes meaningless and the running total walks down as users tidy up their inventory. Root cause was a stats loop in `InventoryPage.tsx` that gated every aggregate (totalConsumed, totalWeight, lowStock, byMaterial, activeCount) behind a single `if (s.archived_at) continue;` check. Fix splits the loop so `totalConsumed` is computed BEFORE the archived-skip and the other aggregates after it, matching the semantic difference between "running counter" and "currently-available inventory". Two adjacent regressions the reporter also surfaced are fixed in the same pass: (a) the per-spool **eraser button** in the inventory card grid used to require `!spool.archived_at && spool.weight_used > 0` — archived spools had no way to zero their tracking counter without first being un-archived. The `archived_at` half of that gate is gone; the `weight_used > 0` half stays. (b) `activeSpoolIds`, the target list for the "Reset all usage" bulk action, used to filter out archived spools — so a Reset-all click left archived consumption stuck in the (now-corrected) totalConsumed total. Renamed to `resetableSpoolIds` and broadened to include archived, so a Reset-all genuinely zeroes the stat in one click. Backend reset endpoints already accept archived IDs (both `inventory.py::reset_spool_usage` and the Spoolman mirror), so this is frontend-only. Inventory-mode parity holds (both modes share `InventoryPage`). i18n: 8 tooltip/confirm strings retranslated across all 8 locales — the "every active spool" / "all {{count}} active spools" wording was now incorrect (archived included), so each locale's `resetAllUsageTooltip` drops "active" and `resetAllUsageConfirm` makes the archived-inclusion explicit ("(archived included)" / "(incluindo as arquivadas)" / "(含已归档)" etc.); parity holds at 4852 leaves. Tests: a new `InventoryPageArchivedConsumed.test.tsx` with a 2-spool fixture (active 300 g + archived 500 g) pins `totalConsumed = 800g` after the fix and asserts the "Reset all spool usage" button stays rendered; a future refactor that re-introduces the archived-skip drops the assertion to "300g" and CI fails. 13 InventoryPage tests + i18n parity + build all green. - **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 `#` 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 `#` inline with the brand/material/colour line; the existing `

` 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 `` 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 `` 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 `` 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 `` 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 && ` 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: ` 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": }` — 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 `/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` — `` 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 `` 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, `` 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 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 `""` 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('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 ` [bed-icon] GCODE ` 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 — 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` — `--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 — 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 — the multer "Only JSON files are allowed" error string from `upload.js:17`, which only matches `uploadJson` not `uploadBundle` — 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 `/health`, parses the JSON, and walks every non-`dataPath` key under `checks` looking for a `version` field — 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 — 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: is currently printing! Toggling may cut power and interrupt the print" copy, and renders the default-variant "Toggle the Home Assistant 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 `...` — 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''` 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/`, 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//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//`; 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/`, the browser resolved `./assets/index-XXX.js` against the current document URL — which doesn't end in a slash, so the URL parser treated `` 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 `@ ` 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=` 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=` 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=`, `new_branch=`) 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=`** 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 `:322 → :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//...`, `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 `: -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 `