# Changelog
All notable changes to Bambuddy will be documented in this file.
## [0.2.5b1] - Unreleased
### 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
- **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
- **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`.
### 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
- **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 `