# Changelog All notable changes to Bambuddy will be documented in this file. ## [0.2.4b2] - Unreleased ### Added - **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. - **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 - **Project cover photo thumbnail too small to recognise the print** ([#1155](https://github.com/maziggy/bambuddy/issues/1155) follow-up, reported by @smandon) — The 40×40 thumbnail @smandon's MakerWorld download workflow relied on for "is this the model I'm looking for?" wasn't readable at that size; he asked for either a larger thumbnail or a click-to-enlarge full preview. Enlarging the thumbnail itself would shift the card layout and cost the dense grid he chose to use for browsing many projects, so the fix keeps the 40×40 thumbnail and shows a portal-mounted 384×384 popover on hover. The popover renders the full image in `object-contain` so tall portrait MakerWorld photos aren't cropped to a square, has `pointer-events-none` so it can't intercept hover and create a flicker loop, and `z-[100]` so it stacks above every sibling card in the grid. **Why a portal:** ProjectCard carries `overflow-hidden` (for its rounded-corner clipping and the color accent bar), so an in-tree popover gets clipped by the card the moment it extends past the card's bounds — exactly the cut-off behaviour @smandon reported on the second iteration. Rendering via `createPortal(..., document.body)` escapes every ancestor clipping context, and `position: fixed` with measurements from `getBoundingClientRect()` keeps the popover pinned next to the thumbnail regardless of where the card sits in the grid. **Edge handling:** if the thumbnail is near the viewport's right edge the popover flips to the LEFT side of the thumbnail; vertical position is clamped so the popover never overflows the window top or bottom. The thumbnail's own `onClick` is `stopPropagation`'d so hovering the popover area never accidentally triggers the parent card's "open project" navigation. 2 new tests in `ProjectsPage.test.tsx` pin the contract: hovering mounts the popover at document.body level (not nested in the card — a future refactor that drops the portal would re-introduce the clipping bug, and the test catches that); leaving unmounts it; the popover img points at the same cover-image URL as the small thumbnail with `object-contain`; cards without a cover_image_filename never mount the portal-rendering component (so a hover doesn't flash an empty preview). - **Spool edit form lost the Extra Colours value on reopen, Dual Color rendered identically to Gradient, and the Sparkle / checkerboard visuals were too subtle** ([#1154](https://github.com/maziggy/bambuddy/issues/1154) follow-up, reported by @maugsburger) — Four issues against the multi-colour swatch work that landed for #1154. **(1) Extra Colours input didn't hydrate on edit reopen:** `ColorSection`'s draft buffer was seeded once via `useState(formData.extra_colors)`, but `SpoolFormModal` opens *before* its own `useEffect` populates `formData` from the spool record — so by the time the saved value landed, the input's local state had already been initialised to `''` and never re-synced. The COLOR preview banner above the input rendered correctly (consumes formData directly), making it obvious the data WAS persisted; only the input was stuck blank, which the user then had to retype to save anything else. Fix: a ref-guarded `useEffect` resyncs `extraColorsDraft` when `formData.extra_colors` changes via an external update (e.g. modal opening with a spool); the ref is updated inside `commitExtraColors` so the user's own typing is round-tripped without the resync clobbering it. **(2) `Dual Color` and `Gradient` produced the same diagonal blend:** `buildColorLayer` in `filamentSwatchHelpers.ts` ran the same `linear-gradient(135deg, ...)` for both effect types, so a "Dual Color" spool was visually indistinguishable from a "Gradient" one. Real dual-colour spools have two distinct bars on the reel — that's the whole point of the variant. Fix: when `effect_type` is `dual-color` or `tri-color`, build the colour layer as `linear-gradient(to right, c1 0% X%, c2 X% Y%, ...)` with CSS double-position stops (so the colour change is a *hard line* rather than a blend region) and equal-width segments across the stops; `gradient` keeps the original 135° smooth blend. The existing `multicolor` conic-gradient path is untouched. **(3) Sparkle effect was almost invisible on card-sized swatches:** the original 4-dot pattern (each ~1px) read fine on the small inline swatch but disappeared on the 60-pixel-tall inventory card banners — exactly where the user actually identifies a spool. Bumped to 13 flecks in mixed sizes (1px / 1.5px / 2px) and varying opacity (0.65 → 1.0) to give a depth-of-field "metal flake" feeling, distinct from solid + multi-colour. **(4) Checkerboard cell density scaled with the swatch:** the previous helper put `repeating-conic-gradient(...)` in the `background-image` and the caller applied `background-size: cover`, so the same 4-cell pattern was either tiny squares on a small swatch or four huge squares on a card-sized banner. Made `buildFilamentBackground()` return `{ backgroundImage, backgroundSize }` with per-layer sizes — painted layers stay `cover`, the checkerboard gets a fixed 12px tile so the cell density stays consistent regardless of element size and clearly reads as a transparency indicator rather than a multi-colour stripe. Updated the three existing call sites (`InventoryPage` group banner + spool card, `ColorSection` preview) to spread the returned style object directly. **8 new frontend tests** cover the four fixes: hard-split contract for Dual/Tri Color (3 tests + 1 regression guard that Dual ≠ Gradient for the same stops); Sparkle prominence (≥ 10 distinct radial-gradient layers in the rendered background); checkerboard density (last `backgroundSize` layer is a fixed pixel value, not `cover`); 4 hydration tests pinning the input restore path (fills when formData arrives via parent update, resyncs when the spool changes mid-form, doesn't clobber live user typing, clears when the new spool has no extra_colors). - **Pending review card and the resulting archive name disagreed; `.gcode.3mf` filename suffix wasn't fully stripped** ([#1152](https://github.com/maziggy/bambuddy/issues/1152) follow-up, reported by @smandon) — Two distinct holes in the original #1152 fix surfaced when @smandon retested on the daily build. **(1) Suffix stripping was incomplete:** Bambu Studio's "Send to printer" dialog typically writes files like `Plate_1.gcode.3mf` (a sliced gcode payload wrapped in a 3MF container), but the archive's display stem was computed via `Path(name).stem`, which only drops the *last* suffix and left the user staring at `Plate_1.gcode` in the archive UI. **(2) The review card and the archive disagreed on what the print was called:** the pending-uploads panel always rendered the raw FTP filename, while the eventual `PrintArchive.print_name` resolved from the 3MF's embedded title (or, with the toggle on `filename`, the filename stem). Net effect: the user saw `Plate_1.gcode` in the review card and `Some Creator's Title` in the archive grid for the same item, with no toggle that flipped both views in lockstep. Fix has three pieces: a new `resolve_display_stem()` helper in `archive.py` that strips `.gcode.3mf` / `.3mf` / `.gcode` (case-insensitive) so both the archive *and* the review-side normalisation produce the same canonical stem; a new `PendingUpload.metadata_print_name` column populated at FTP-receive time by peeking at the 3MF's embedded title (so `/pending-uploads/` list calls don't have to reopen every 3MF on every render); and a new `PendingUploadResponse.display_name` computed field that mirrors `archive_print`'s exact precedence — `filename` toggle: stripped stem; `metadata` toggle (default): cached title or stripped stem. Frontend's `PendingUploadsPanel` reads `upload.display_name` (with `upload.filename` as a defensive fallback for any pre-migration row), and the raw filename is exposed as a tooltip so users can still inspect what actually arrived over FTP. Migration is one idempotent `ALTER TABLE pending_uploads ADD COLUMN metadata_print_name VARCHAR(255)` (Postgres/SQLite-safe); existing pending rows have NULL there and gracefully fall back to filename-stem behaviour. 14 unit tests pin the stripping rules (`Plate_1.gcode.3mf` → `Plate_1`, mixed case, dots in the middle, edge `.3mf`-only / `.gcode`-only, full-path inputs); 6 integration tests pin the response contract (default toggle uses metadata title when present, falls back to stripped stem when absent, `filename` toggle overrides metadata, `filename` toggle still strips the double suffix, `GET /{id}` exposes the same field, whitespace-only metadata behaves like absent); 3 frontend tests pin the review card's render path (resolved name shown, fallback to filename when display_name is empty, raw filename available via tooltip). - **SpoolBuddy SSH update fails with "permission denied for user spoolbuddy" after Bambuddy keypair rotation** (reported during user testing) — Bambuddy's data dir at `/spoolbuddy/ssh/` can get recreated outside the daemon's control (volume remount, container recreate, fresh deploy), at which point `get_or_create_keypair()` generates a new ed25519 keypair. The SpoolBuddy daemon previously only fetched and deployed Bambuddy's public key at registration time (`/devices/register`), so any rotation after a successful registration left the device's `~/.ssh/authorized_keys` pointing at a defunct public half — every "Update" click from the Bambuddy UI then failed with `Connection closed by authenticating user spoolbuddy [preauth]` until the daemon was restarted manually. Worse, every prior successful registration appended a fresh entry to `authorized_keys` without ever pruning the old one, so a typical device accumulated 5+ stale Bambuddy-tagged keys (each one a permanent backdoor for whichever Bambuddy keypair held the matching private half at the time it was deployed). Two-pronged fix: **(1)** the heartbeat response (`HeartbeatResponse`, `routes/spoolbuddy.py:282`) now carries the current `ssh_public_key` alongside the existing `pending_command` / calibration fields, so the daemon's heartbeat picks up a key rotation within one cycle instead of needing a service restart; the same `try/except Exception: pass` pattern as the registration response keeps a missing/unreadable backend key from breaking telemetry. **(2)** `_deploy_ssh_key()` in `daemon/main.py` now syncs rather than appends — it strips every line tagged `bambuddy-spoolbuddy`, writes the current key once, and is a no-op when already in sync (so it doesn't churn the file every heartbeat). User-managed entries (any line not tagged `bambuddy-spoolbuddy`) are preserved untouched. 5 new unit tests in `spoolbuddy/tests/test_deploy_ssh_key.py` (creates-when-missing → mode-600 file with the current key; pile-up-of-stale-keys → only current key remains, no growth; preserves-unrelated-user-keys → user's own SSH access untouched; idempotent-when-in-sync → no mtime change so heartbeat doesn't churn the file; swallows-write-errors → readonly-fs PermissionError doesn't crash the heartbeat loop). 2 new backend integration tests in `test_spoolbuddy.py::TestDeviceEndpoints` — `test_heartbeat_returns_ssh_public_key` (response carries the key on every heartbeat) and `test_heartbeat_ssh_key_failure_does_not_break_heartbeat` (backend key-read failure leaves `ssh_public_key: None` but the heartbeat still 200s). - **External-camera frames returned as black on go2rtc and other MJPEG sources** ([#1177](https://github.com/maziggy/bambuddy/issues/1177), reported by @nkm8) — `_capture_mjpeg_frame` returned the very first JPEG it found in the stream's bytes (`backend/app/services/external_camera.py:282`), but many MJPEG sources — go2rtc most notably, and several IP cameras — emit a "warm-up" frame on the byte that follows connection accept: usually the last keyframe held in the encoder, which is often black or stale until the encoder catches up to live content. Subsequent frames on the same connection are fine. The reporter saw it across snapshot UX, finish photos in notifications, and timelapse — every code path that opens a fresh capture connection (snapshot endpoint, `[PHOTO-BG]` finish photo, plate-detection CV, Obico ML inference, layer timelapse, Settings → Test). His own observation that go2rtc's `/api/frame.jpeg` (single-frame, internally already warmed) is never black while the first frame off `/api/stream.mjpeg` is, matched the hypothesis exactly. Support-bundle evidence was clean: every black notification frame in his log was 11095 bytes (a pure-black 1280×720 JPEG encodes to ~10–15 KB on standard libjpeg quality settings), while every captured-after-warm-up frame from the same source was 30–45 KB. Fix: read past the first frame and return the second; if the connection closes / times out / hits the 5 MB buffer cap before a second frame ever arrives, fall back to the first so callers still get *something* (degrading slow / single-frame streams to None would regress every code path that relied on pre-fix behaviour). The inner-loop now drains every complete frame already in the buffer before pulling the next chunk so high-FPS sources that pack multiple frames per chunk are handled correctly. The `snapshot` / `rtsp` / `usb` capture paths and the live-view streaming endpoint (`generate_mjpeg_stream`) are untouched. 7 new regression tests in `test_external_camera.py::TestCaptureMjpegFrameWarmupSkip` cover (a) two-frames-in-two-chunks → second returned, (b) two-frames-in-one-chunk → second returned, (c) frame split across chunk boundary → assembled correctly, (d) single-frame stream → first returned via fallback (no None regression), (e) timeout after first frame → first returned via fallback, (f) zero-frame stream → None, (g) non-200 status → None. Latency penalty: at most one frame interval (typically 50 ms – 1 s on a steady stream). - **MakerWorld sidebar entry visible to every user regardless of group permissions** ([#1175](https://github.com/maziggy/bambuddy/issues/1175)) — Backend already enforced `makerworld:view` on every `/makerworld/*` route (`backend/app/api/routes/makerworld.py:145, 157, 242, 406`), the permission was correctly granted to the admin and standard-user role defaults (`permissions.py:298, 364, 454`), and the frontend `Permission` type union already included `'makerworld:view' | 'makerworld:import'` (`client.ts:2498`) — but the sidebar's hand-maintained `navPermissions` map in `Layout.tsx:278` had no entry for `makerworld`, so `isHidden('makerworld')` always returned false and the entry rendered for every authenticated user. Users without the permission saw the entry, clicked, and the page rendered while every API call inside it 403'd. Two-line fix: (1) `Layout.tsx:278` — add `makerworld: 'makerworld:view'` to the map, matching every other sidebar entry's gating shape; (2) `App.tsx:200` — wrap the route in `` for defence in depth, so a user who knows the URL can no longer reach the page directly (matches the existing pattern on `settings`, `groups/new`, `groups/:id/edit` two lines below). 2 new Layout tests pin the contract: with auth enabled and a user lacking `makerworld:view`, the sidebar `` link is absent (other links like `/files` still render); with the permission granted, the link renders. - **Printer Info modal: serial-number and IP-address copy buttons silently did nothing on plain-HTTP LAN deployments** ([#1174](https://github.com/maziggy/bambuddy/issues/1174), reported by @BurntOutHylian) — `PrinterInfoModal`'s `CopyButton` only tried `navigator.clipboard.writeText()`, which is gated by the secure-context requirement (HTTPS or localhost). On the typical Bambuddy deployment shape — bare-IP HTTP on the LAN — `navigator.clipboard` is undefined; the existing `try/catch` swallowed the resulting `TypeError`, the icon never flipped to the tick, and nothing landed on the user's clipboard. Fixed by adding the same off-screen-textarea + `document.execCommand('copy')` fallback that `CameraTokensPage`'s plaintext-token modal already uses for plain-HTTP LAN deployments: gate on `navigator.clipboard && window.isSecureContext`, fall back to the legacy path otherwise, and surface the success-tick only when the copy actually landed (return early without flipping `copied` if `execCommand('copy')` returns false). The `try/finally` around the textarea guarantees DOM cleanup even when the browser throws on a restricted context. 3 new component tests in `PrinterInfoModal.test.tsx` cover (a) secure-context happy path uses `navigator.clipboard.writeText`, (b) plain-HTTP fallback path actually invokes `execCommand('copy')` and leaves no leaked textarea in the DOM, (c) `finally` cleanup removes the textarea even when `execCommand` throws synthetically. Thanks to @BurntOutHylian for the precise file/line pointer in the report. - **Queue auto-dispatched the next print onto a fouled bed after an aborted or cancelled print** ([#1171](https://github.com/maziggy/bambuddy/issues/1171), reported by @tom5677) — When a print ended with status `aborted` (printer self-abort, or a user stopping the print on the printer's own touchscreen) or `cancelled` (user stopping the print via the Bambuddy queue UI), the plate-clear gate added in [#961](https://github.com/maziggy/bambuddy/issues/961) was *not* raised — only `completed` and `failed` triggered it (`backend/app/main.py:2660`). Result: the queue scheduler dispatched the next pending item ~2 seconds after the abort, with the previous print's material still on the bed. The reporter saw two prints (P1P + P1S) auto-start onto fouled beds within seconds of each other after touchscreen-aborts, and explicitly flagged the risk of damage to the printer; a third printer (his second P1S) behaved correctly because its previous print had ended `completed`. The original code's comment ("user-cancelled prints don't require a plate-clear ack — nothing printed on the bed") only holds if you cancel right at layer 1; cancelling a 12-hour print at hour 11 leaves a fouled bed too. Fix: the gate is now raised for every terminal status — `completed`, `failed`, `aborted`, `cancelled` — matching the safety contract that the user must acknowledge the bed is clear before any next queued print starts. The gate is user-clearable on the Printers page, so worst case for a layer-1 cancel the user clicks "Clear Plate" once. Touchscreen-aborts are particularly important to gate because Bambuddy's "user stopped via UI" override (`_user_stopped_printers` → `aborted` mapped to `cancelled`) only fires when the user stops via the Bambuddy queue; a touchscreen-stop reports `aborted` straight through. Regression coverage in `test_print_lifecycle.py::TestPlateClearGate`: parametrised across all four terminal statuses (asserts `set_awaiting_plate_clear(printer_id, True)` is called for each), plus a defence-in-depth test that an unrecognised future status string never silently raises the gate. - **Printer card always shows the first plate's thumbnail when printing a multi-plate 3MF** ([#1166](https://github.com/maziggy/bambuddy/issues/1166), reported by @smandon) — On printers running firmware that drops the plate path from `print.gcode_file` (the reporter's case: P1S 01.10.00.00, but the same shape appears on other firmware revisions), the printer reports `gcode_file: MyModel.3mf` instead of `gcode_file: /Metadata/plate_4.gcode`. The `/printers/{id}/cover` route's regex (`plate_(\d+)\.gcode`) found nothing in the bare `.3mf` filename, defaulted to plate 1, and the printer card showed `Metadata/plate_1.png` from the 3MF — even though the user dispatched plate 4. Same problem hit `current_plate_id` on the status response (printer card detail row showed plate 1). Two-pronged fix on a precedence ladder: **(1) Bambuddy now records the plate it dispatched** — `start_print()` writes `(dispatched_plate_id, dispatched_subtask)` onto `PrinterState` at publish time, and a new `resolve_plate_id(state)` helper prefers that record over the gcode_file regex when `dispatched_subtask == state.subtask_name` (the subtask check rejects stale entries from a prior Bambuddy-dispatched print bleeding into a Studio-direct dispatch). **(2) After the 3MF lands on disk, the cover route scans the zip for a unique `Metadata/plate_*.gcode` entry**: per-plate archives sliced separately in Bambu Studio bundle thumbnails for *every* plate but only the *active* plate's gcode, so a single match unambiguously identifies the plate even when no Bambuddy dispatch exists (Studio-direct flow). Final fallback is plate 1, unchanged. The cover-byte cache key was also simplified — `plate_num` was removed from the key now that resolution is late-bound; `clear_cover_cache()` already runs on every print start, so different plates of the same project always re-fetch a fresh thumbnail. Coverage: 5 unit tests in `test_printer_manager.py::TestResolvePlateId` (dispatch precedence, stale-subtask guard, gcode regex fallback, default-1 path, missing-subtask guard), 4 unit tests in `test_bambu_mqtt.py::TestStartPrintRecordsDispatchedPlate` (dispatch record set/cleared/overwritten/skipped on disconnect), 2 integration tests in `test_printers_api.py` (dispatch wins over plate-1 default; 3MF-scan fallback for per-plate archive without dispatch). Studio-direct multi-plate prints (no dispatch record AND multiple plate gcodes in the 3MF) still default to plate 1 — matches the firmware's own ambiguity, not regressed by this change. - **AMS slot configuration intermittently fails to reach the printer after several configs in a row** ([#1164](https://github.com/maziggy/bambuddy/issues/1164), reported by @RosdasHH) — Configuring AMS slots a handful of times (the reporter saw it almost every 6th change) would silently stop reaching the printer; ~1 minute later the filament colours on the printer would briefly jump between slots, then settle. Root cause was the zombie-session watchdog at `bambu_mqtt.py:861` introduced for [#887](https://github.com/maziggy/bambuddy/issues/887). When an `ams_filament_setting` response took >10 s (normal under load — concurrent K-profile fetches, busy printer, network jitter) the watchdog incremented an `_ams_cmd_unanswered` counter and zeroed `_last_ams_cmd_time` so it wouldn't re-trigger on the next status push. The bug: the response handler that reset the counter was guarded by `and self._last_ams_cmd_time > 0` — so when the late response *did* arrive (after the watchdog had already zeroed the timer), the counter stayed armed at 1. The next slow response on any `ams_filament_setting` command — possibly minutes or hours later, on an entirely unrelated config attempt — would take the counter to 2 and trigger `force_reconnect_stale_session()`. The user-visible symptoms match exactly: configs stop landing (because MQTT reconnects mid-publish, dropping the in-flight command and surfacing as `Cannot set AMS filament setting: not connected` if the user retries during the ~1 min reconnect window), then the queued state finally lands when the reconnect completes (the "filament colours jumping around" the reporter described). Fix is to drop the `_last_ams_cmd_time > 0` guard: any `ams_filament_setting` response — late or not — proves the channel is alive, so the counter must reset. Watchdog still trips on a real zombie session (no responses at all for two consecutive >10 s windows). Regression test in `test_bambu_mqtt.py::TestZombieSessionDetection::test_late_response_after_watchdog_clears_counter_issue_1164` simulates the exact sequence (watchdog fires → late response arrives → second slow response on a fresh command) and asserts the counter resets to 0 on the late response and the second command doesn't tip the threshold to 2. Other 10 zombie-detection tests still pass unchanged. ## [0.2.4b1] - 2026-04-29 ### Added - **Enhanced filament colour handling: multi-colour gradients, transparency, visual effects** ([#1154](https://github.com/maziggy/bambuddy/issues/1154)) — A solid hex swatch is the wrong abstraction for a tri-colour, gradient, or sparkle filament — the colour you saw on the spool inventory page was just whatever Bambu's firmware reported as the dominant tone, and there was no way to record what the spool actually looked like. The Spool form's "Colour" section now accepts a paste of up to 8 comma-separated hex stops (`EC984C,#6CD4BC,A66EB9,D87694` — exact format from 3dfilamentprofiles.com) and renders them as a CSS gradient on every swatch site (inventory grid, table, group banner, card, ColorSection preview, color-catalog admin). A new **Effect** dropdown — covering surface effects (Sparkle / Wood / Marble / Glow / Matte), sheen variants (Silk / Galaxy / Rainbow / Metal / Translucent), and structural variants (Gradient / Dual Color / Tri Color / Multicolor) — layers a CSS overlay on top of the colour layer (or, for Multicolor, switches the colour layer to a conic-gradient even when no spool subtype is set, so the catalog editor can flag a multicolor variant directly without needing a paired Spool row). Independent of `subtype` — so the user can override the visual hint without touching Bambu's categorical filament label or the MQTT auto-detection chain. **Transparency is now actually visible**: the existing `rgba` column has always stored an alpha byte but every render site flattened it with `substring(0, 6)`; the new shared `` component renders against a checkerboard layer beneath the colour layer so any alpha < 0xFF shows through (matches the convention used by image editors and 3dfilamentprofiles.com). **Multicolor subtype** swaps the linear gradient for a `conic-gradient` so the swatch reads as a colour wheel pie instead of a stripe — visually distinguishes a true multi-colour spool from a 2-stop gradient. **Colour-catalog parity**: the same fields land on `ColorCatalogEntry` (Settings → Color Catalog) so a user can save a multi-colour combo once and pick it from the catalog palette across spools — added inline to both the Add form and the inline-edit row, threaded through the JSON export/import path so catalog backups round-trip the new fields. Catalog `hex_color` regex extended to optionally accept `#RRGGBBAA` for transparency-aware catalog entries (backward-compatible — existing 6-char rows still validate). **Schema validation** (`backend/app/schemas/spool.py::normalize_extra_colors` + `normalize_effect_type` — public so `ColorEntryCreate` / `ColorEntryUpdate` can reuse them): comma-separated hex with 6-or-8-char tokens, lowercase canonical form, `#` prefix stripped, max-8-stop cap, empty tokens dropped (so a degenerate paste like `,,FF0000,` survives), invalid tokens rejected at the Pydantic layer with a precise field error. Effect type validated against the fixed set `{sparkle, wood, marble, glow, matte, silk, galaxy, rainbow, metal, translucent, gradient, dual-color, tri-color, multicolor}` — paste-friendly normaliser tolerates `Dual Color` / `dual_color` / `dual-color` and canonicalises to `dual-color`. Both validators live next to `SpoolBase` and are reused by `ColorEntryCreate` / `ColorEntryUpdate` so spool-side and catalog-side rejection rules can never drift. **Frontend swatch component** is one shared `` (and `buildFilamentBackground()` helper for callers that want just the CSS background-image string for a banner) — used by InventoryPage table, group banner, SpoolCard, ColorSection preview, and ColorCatalogSettings — so there's exactly one place that decides how a filament looks. Colour layer is built as a list of CSS images (no `background:` shorthand) so jsdom and every browser parse it consistently; checkerboard layer is the one that makes alpha visible. Migrations are 4 idempotent `ALTER TABLE ... ADD COLUMN` (Postgres-safe, no `DEFAULT 0` traps) plus a Postgres-only widen of `color_catalog.hex_color` to `VARCHAR(9)`. **i18n**: 12 new keys under `inventory.*` across all 8 locales (en/de/zh-CN/zh-TW fully translated; fr/it/ja/pt-BR seeded with English copies pending native translation, matching the project's flow for newly-added user-facing features). 42 new backend tests (35 unit + 7 integration) covering the normalizer (paste-from-3dfilamentprofiles canonicalisation, whitespace tolerance, mixed 6/8-char, empty-token drop, max-stop cap, invalid-hex rejection, wrong-length rejection, `Dual Color` / `dual_color` → `dual-color` canonicalisation), effect-type validator across all 14 allowed values, end-to-end POST/PUT/PATCH round-trip on both spool + catalog routes, 8-char `hex_color` acceptance, dedupe-on-update, and field clearing via empty string vs explicit null. 20 new frontend tests covering FilamentSwatch (14 — solid render, multi-stop linear gradient, conic for Multicolor *subtype* AND for `multicolor` *effect_type* via the catalog path, surface-effect overlays for sparkle and silk, categorical-only-no-overlay for gradient/dual-color, unknown-effect-ignored, checkerboard rendering for alpha, invalid-hex skip in stops, title fallback), `buildFilamentBackground` helper, ColorCatalogSettings (3 — Add form sends extra_colors + effect_type, full 14-value dropdown, inline edit hydrates from existing entry), and InventoryPage spool grouping (3 — different extra_colors don't collapse, different effect_type don't collapse, identical multi-colour spools still group). Out of scope for V1: gradient stop *positions* (e.g. 25%/75%), MQTT-side auto-import of multi-colour from Bambu (firmware doesn't expose this), per-effect tunable parameters — the current shape closes the user's actual paste-from-3dfilamentprofiles workflow without taking on a structured-stop-position editor. - **Project URL + cover photo** ([#1155](https://github.com/maziggy/bambuddy/issues/1155)) — Two new fields on every project: a free-text **URL** (rendered as a 24×24 bordered green button beside the project name on every card; opens in a new tab and click is `e.stopPropagation()`-guarded so it doesn't enter the project) and a **cover photo** (replaces the status-icon box on the card with a square thumbnail). The URL field is plumbed through `ProjectCreate`/`ProjectUpdate`/`ProjectResponse`/`ProjectListResponse`, including from-template + create-template flows so the URL inherits between a project and its template; cover photo is *not* inherited because the file would be shared on disk between the source and copy. **Schema validator** rejects anything other than `http://` or `https://` prefixes — `` rendering would otherwise execute `javascript:` / `data:` / `file:` URLs even with React's default escaping. **Cover image storage**: `Project.cover_image_filename` references a file inside the existing `archives/projects/{id}/attachments/` directory, but it's tracked as a separate column from the `attachments` JSON list so swap/delete operations on the cover don't perturb the user's other attachments. Three new routes (`POST /projects/{id}/cover-image`, `GET /projects/{id}/cover-image`, `DELETE /projects/{id}/cover-image`) — accepts only `.jpg`/`.jpeg`/`.png`/`.gif`/`.webp` (no SVG: SVG can carry script payloads), replaces in place (the prior file is removed from disk before the new one lands so repeat uploads can't accumulate orphans), and self-heals when a DB reference points at a vanished disk file by clearing the column and 404'ing rather than repeatedly touching the filesystem. **GET auth gate**: the cover-image GET route is gated by `RequireCameraStreamTokenIfAuthEnabled` (accepts the same `?token=…` stream credential the archive thumbnail route uses) rather than the bearer-token gate — `` requests can't carry an `Authorization` header, and the bearer gate would silently 401 every cover image when auth is enabled. The frontend client wraps the URL with `withStreamToken(...)` so the modal preview AND the card thumbnail load in both auth-on and auth-off configurations. **PATCH update** uses `model_fields_set` for the URL field so users can clear it by sending `{"url": null}`. Permissions: `PROJECTS_UPDATE` for upload/delete/PATCH, `PROJECTS_READ` for the GET (via the stream-token gate). **Migration**: 2 idempotent `ALTER TABLE projects ADD COLUMN ...` statements. Localised across all 8 UI languages (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW) — English fully translated, the seven other locales seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features. 7 backend integration tests covering URL accept/reject (https/javascript/data), URL clear, cover image upload→serve→delete round-trip with content-type assertion, non-image rejection, and a regression guard verifying the GET route is wired to the stream-token gate (not the bearer gate). 4 frontend ProjectsPage tests covering the link icon render condition, click-propagation guard, no-link-when-unset, and cover-image thumbnail render; 3 frontend client tests pinning that `getProjectCoverImageUrl` appends the stream token, returns the bare URL when no token is set, and URL-encodes tokens with query-string-unsafe characters. Cover image upload is only available on the edit modal (an existing project), since the upload needs a project_id; new projects can add it after first save. - **"Not Printed" / "Printed" collections on the Archives page** ([#1153](https://github.com/maziggy/bambuddy/issues/1153)) — Virtual-printer uploads land in the archives view with `status='archived'` (uploaded but never sent to a printer), but the existing `Collection` sidebar only had `All / Recent / This Week / This Month / Favorites / Failed / Duplicates` so there was no way to surface "what's still queued in my library that I haven't printed yet" vs "what already went to a printer." Two new collections fill that gap: **Not Printed** filters to `status === 'archived'` (the VP upload state); **Printed** filters to any final-status archive — `completed`, `failed`, `aborted`, `cancelled`, `stopped` — so a user can see every archive that had a print attempt regardless of outcome (the existing "Failed" collection covers just the failure subset). Frontend-only — the data has always been there, just no UI handle for it. 2 new tests in `ArchivesPage.test.tsx::Not Printed / Printed collections` pin the filter behaviour against a fixture covering all 4 status states (archived / completed / failed / cancelled). - **Virtual-printer archive name source toggle** ([#1152](https://github.com/maziggy/bambuddy/issues/1152)) — Slicer-uploaded archives picked up their display name from the 3MF's embedded `print_name` metadata, which is whatever the original creator set; users who renamed a job in BambuStudio's "Send to printer" dialog never saw that name surface in Bambuddy because the FTP-uploaded filename was only ever used as a fallback when the metadata was empty. Settings → Virtual Printer now exposes an **Archive name source** toggle (Metadata / Filename, default Metadata, preserves existing behaviour) at the top of the page that flips precedence in `ArchiveService.archive_print` for every VP-sourced archive — `_archive_file`, `_add_to_print_queue`, `POST /pending-uploads/archive-all`, and `POST /pending-uploads/{id}/archive` all read the new `virtual_printer_archive_name_source` setting and forward `prefer_filename_for_name` accordingly. Backend validates the value to `metadata`/`filename` only. Strict locales (en/de/zh-CN/zh-TW) get full translations; 4 unit tests parametrised over `filename` / `metadata` / unset / empty-string pin the precedence rule end-to-end through `_archive_file`. Existing post-archive `PATCH /archives/{id}` rename path is unchanged. - **Multi-color slicing in the Slice modal, with per-plate filament discovery for unsliced project files** — Initial slice support assumed a single filament profile per slice; multi-color 3MFs were silently truncated to the first slot, producing wrong colours on every non-trivial print. The Slice modal now (1) opens a plate-picker step first when the source is a multi-plate 3MF, (2) renders one filament dropdown per AMS slot the picked plate actually uses, with each dropdown auto-populated against the user's local + standard presets by `(filament_type, filament_colour)` match, and (3) submits the user's picks as an ordered `filament_presets: PresetRef[]` array which is forwarded as repeated `filamentProfile` multipart parts to the slicer sidecar (the CLI joins them with `;` for `--load-filaments`). **Per-plate filament list source-of-truth chain**: for a sliced archive the modal reads `Metadata/slice_info.config` directly (existing path); for an unsliced project file (where `slice_info.config` is empty until Bambu Studio actually slices), the new `slice_preview` service runs a fast preview-slice via the sidecar's `slice_without_profiles` (the project's embedded settings drive the slice; we throw away the gcode and only parse the resulting slice_info), and the result is cached by `(kind, source_id, plate_id, content_hash)` with LRU eviction at 256 entries — repeat opens of the same plate are instant. If the sidecar isn't reachable the modal falls back to a heuristic that reads `Metadata/project_settings.config` for the AMS slot config and intersects it with the plate's painted-face data (`paint_color` quadtree leaves on per-object .model files, scanned with a 5% noise threshold to drop single-leaf edit accidents). **SliceModal-only tier priority is now `local → cloud → standard`** (was `cloud → local → standard`): imported profiles win because they carry parsed type/colour metadata in the response, while cloud entries don't (the per-preset detail endpoint rate-limits at ~10/sec per token and 50+ parallel fetches returned 429 on every request). The unified-listing endpoint's dedup pass now backfills metadata cross-tier — if a cloud entry wins dedup over a same-named local entry, the cloud entry inherits the local's `filament_type` / `filament_colour` so the Slice modal's metadata-aware pre-pick keeps working for users who have presets both cloud-synced and locally imported. Other consumers of `/slicer/presets` (Profiles page, etc.) retain the existing cloud-first dedup. **Sidecar** (orca-slicer-api fork, `bambuddy/profile-resolver` branch): `/slice` now accepts up to 16 repeated `filamentProfile` parts (was hard-capped at 1), the slicing service materializes each as `filament_N.json` and joins paths into a single `--load-filaments "a.json;b.json;c.json"` invocation; `/profiles/bundled` listing was extended with `filament_type` and `filament_colour` per leaf so the bundled tier carries metadata into the modal. **Sliced-archive card now reflects the actually-used filament list, not the project-wide AMS config**: `slice_and_persist_as_archive` previously copied `filament_type` and `filament_color` from the unsliced source archive verbatim, which inherited every project-wide AMS slot (16+ swatches on the card for a 2-color print). The new archive now reads those fields from the sliced output's `slice_info.config` via `ThreeMFParser` (which already gates on `used_g > 0`), falling back to the source archive's values only if parsing failed. **Backwards compatibility**: `SliceRequest` schema accepts three shapes — legacy `filament_preset_id: int`, source-aware singular `filament_preset: PresetRef`, multi-color array `filament_presets: list[PresetRef]` — the validator promotes any of them into a populated `filament_presets` list before the route handler runs, and stale browser tabs from before this change keep working unchanged. **Permissions**: no new endpoint paths added; the preview-slice runs inside `/filament-requirements` (gated on `LIBRARY_READ` / `ARCHIVES_READ`) and the multi-filament dispatch runs inside `POST /slice` (gated on `LIBRARY_UPLOAD`) — no auth surface widened. **Tests**: 6 schema tests for `SliceRequest` covering the multi-filament list shape and legacy-vs-new precedence; 9 unit tests for `slice_preview` covering happy path, content-hash invalidation, sidecar-failure no-cache-poison, concurrent-call thundering-herd guard via per-key `asyncio.Lock`, and LRU eviction-with-lock-cleanup; 15 unit tests for `extract_project_filaments_from_3mf` (5 cases) and `extract_plate_extruder_set_from_3mf` (10 cases including the 60/40 painted-threshold pin); a multi-filament wire-format test on `slice_with_profiles` pinning that N filament profiles produce N repeated multipart parts in submission order; 22 frontend SliceModal tests covering the plate picker step, multi-color rendering, metadata-aware pre-pick, manual slot override, archive-vs-library routing, and the new tier order. Localised across all 8 UI languages (English + German fully translated, the six others seeded with English copies pending native translation per the project's existing flow). - **Slicer presets now span Cloud, imported, and slicer-bundled tiers, end-to-end** — Initial slicer integration only saw DB-backed local imports, so a user without imported profiles got an empty Slice modal even when their Bambu Cloud account or the slicer sidecar carried perfectly usable presets. The Slice modal now pulls from three tiers in priority order — **cloud** (the user's own Bambu Cloud presets), **local** (DB-backed imports), **standard** (slicer-bundled stock profiles) — with name-based dedup so a preset that exists in multiple tiers only renders in the highest-priority one (cloud > local > standard) and within-tier order is preserved exactly. **Listing** (`GET /api/v1/slicer/presets`): cloud branch is per-user with a 5-minute cache keyed on `(user_id, sha256(token)[:16])` so a logout/login or token rotation auto-invalidates without callback wiring from the cloud-auth routes. Bundled branch is global with a 1-hour cache (sidecar's read-only filesystem only changes across image rebuilds). `cloud_status` (`ok` / `not_authenticated` / `expired` / `unreachable`) drives a precise modal banner instead of an unexplained empty list. **Slicing** (`POST /library/files/{id}/slice`, `POST /archives/{id}/slice`): request body now accepts source-aware `{source, id}` triplets per slot (cloud / local / standard) alongside the legacy `*_preset_id` fields for full backwards-compatibility — the schema validator normalises bare integer ids into `PresetRef(source='local', id=str(int))` so the dispatcher only deals with one shape. New `preset_resolver` service fetches the preset content per source: cloud via `BambuCloudService.get_setting_detail` (unwraps the `setting` envelope, falls back to top-level on minor shape variants), local from the DB (existing path), standard via a minimal `{inherits: , from: "system"}` stub that the sidecar's `bambuddy/profile-resolver` branch flattens against `BUNDLED_PROFILES_PATH//.json` — no preset-content round-trip needed for the standard tier. **Permissions**: the listing route gate matches the slice action itself (`LIBRARY_UPLOAD`) so any user who can slice can populate the dropdowns; the cloud branch has an independent `CLOUD_AUTH` check inside the fetch helper — a user holding `LIBRARY_UPLOAD` but not `CLOUD_AUTH` doesn't see the cloud tier (and can't slice with a cloud preset, returns 403) even if a leftover `User.cloud_token` survived a permission revocation. **SliceModal** (frontend): grouped `` per tier with localised section headers, default-selection follows the cloud > local > standard priority on first load, cloud-status banner with three variants (sign-in / expired / unreachable) only when the status isn't `ok`. **Sidecar** (orca-slicer-api fork, `bambuddy/profile-resolver` branch): new `GET /profiles/bundled` walks `BUNDLED_PROFILES_PATH/{machine,process,filament}` and returns instantiable presets only (`instantiation: "true"`), filtering out abstract bases like `fdm_filament_pla` so the dropdowns only offer things a user can actually pick. **Tests**: 17 unit tests for the listing endpoint helpers (dedup priority + per-slot scoping + order preservation, all four `cloud_status` states, `CLOUD_AUTH` defence-in-depth with token lookup short-circuit, per-user cache isolation, token-change cache invalidation, sidecar-unreachable fallback), 11 unit tests for the source-aware resolver (standard inherits-stub shape, local DB lookup with `preset_type` validation, cloud envelope unwrapping with both standard and top-level shapes, cloud auth-error → 401, cloud `CLOUD_AUTH` defence, slot dispatch routing), 6 schema tests for `SliceRequest` covering legacy bare-int normalisation and new source-aware refs and explicit-ref-wins-over-legacy precedence, 12 frontend tests for SliceModal covering tier-priority auto-selection, `` grouping, fallback when higher tiers are empty, source-aware payload on submit, manual override across tiers, archive-vs-library routing, error display, and all three banner variants. All 3391 backend + 1531 frontend tests pass. - **Server-side slicing via OrcaSlicer / Bambu Studio sidecar** — Bambuddy can now slice models without a desktop slicer installed. New optional `slicer-api/` Compose stack runs HTTP wrappers around the OrcaSlicer and/or Bambu Studio CLI; Bambuddy's File Manager and Archives pages get a **Slice** button that picks a printer / process / filament preset and dispatches a background slice job whose result lands as a new `.gcode.3mf` in the same library folder (or as a new archive when the source was an archive). Settings → Workflow gets a new **Slicer** card: pick the preferred slicer, toggle "Use Slicer API" on, and paste the sidecar URL — Slice buttons across File Manager, Archives, and MakerWorld then route through the API instead of the OS slicer URI scheme. Status updates come from a global `SliceJobTrackerProvider` that polls `/api/v1/slice-jobs/{id}` and surfaces a single toast per job (queued → running → completed / failed) plus auto-refreshes the file or archive list on success — slicing one file no longer pins the modal. Server side, a fresh in-memory dispatcher (`backend/app/services/slice_dispatch.py`) runs jobs as `asyncio.create_task`s with a 30-minute retention sweep, and the routes (`POST /library/files/{id}/slice`, `POST /archives/{id}/slice`) return 202 immediately with `{job_id, status, status_url}` instead of holding the request open through a multi-minute slice. The CLI bridge (`backend/app/services/slicer_api.py`) distinguishes 4xx (`SlicerInputError`), 5xx (`SlicerApiServerError`), and connection failures (`SlicerApiUnavailableError`) so 3MF inputs can transparently retry with embedded settings when the sidecar's `--load-settings` path segfaults on the input — empirically required for OrcaSlicer 2.3.x + H2D and signalled to the UI via `used_embedded_settings: true`. Sliced output is forced to `.gcode.3mf` so File Manager picks up the embedded thumbnail, the `print_name` is dropped from saved metadata so the displayed filename matches what the user picked, and `file_type="gcode"` paints the badge blue. The polling endpoint `GET /api/v1/slice-jobs/{id}` is gated on `LIBRARY_READ` since job IDs are sequential and the body leaks source filenames + resulting library/archive IDs. The sidecar itself builds from a fork of [AFKFelix/orca-slicer-api](https://github.com/AFKFelix/orca-slicer-api) (`maziggy/orca-slicer-api@bambuddy/profile-resolver`) which adds the `inherits:` chain resolver, `from: "User"` → `"system"` rewrite, `# ` clone-prefix strip, and sentinel-value strip empirically required to slice real OrcaSlicer GUI exports without segfaulting the CLI; the Compose file uses Docker's git-build-context so users don't clone it manually. Default ports are 3003 (orca) and 3001 (bambu-studio) — 3000/3002 are skipped because Bambuddy's virtual-printer feature owns them. 10 backend integration tests cover sync validation (404/400), happy-path enqueue, preset-error → failed job, sidecar unreachable, the 3MF embedded-settings fallback, STL no-fallback, and the strip-before-forward path; 5 new frontend tests for the SliceModal cover preset gating, library + archive enqueue paths, error display, and preset-load failure. New i18n keys under `slicer.*` and `settings.slicer.*` across all 8 locales (English fully translated; the seven other locales seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features). Slicer integration is opt-in: if "Use Slicer API" stays off, the existing "open in desktop slicer via URI" flow is the default and unchanged. - **Per-spool category + low-stock threshold override** ([#729](https://github.com/maziggy/bambuddy/issues/729) — minimal version) — Two new fields on the spool form: a free-text **Category** (with autocomplete from categories already in use, so users naturally re-use "Production" instead of accidentally typing "production" / "prod") and a per-spool **Low-stock threshold (%)** override that defaults to the global setting if left blank. Powers the "I want to differentiate critical spools from prototype spools and alert at different thresholds" use case from the issue without taking on the full multi-tag taxonomy + auto-apply-rules + per-tag alert system the ticket originally proposed (which would have been ~5x the work for the same underlying value). Inventory page gains a Category filter chip — only renders once at least one spool carries a category, otherwise hidden so the chip row stays uncluttered. Low-stock counts in the stat-card and the "Low Stock" filter both honour the per-spool override (so a "Production" spool with override = 90% will count as low-stock at 80% remaining even when the global threshold is 20%). 50-char cap on category, 1-99% range on threshold (0 and 100 are both rejected as footguns). 9 new backend schema-validation tests covering the field defaults, partial-update behaviour, range/length rejection; 2 new frontend tests confirming the per-spool threshold pulls in spools the global threshold misses, and that the category filter chip stays hidden until at least one spool has a category. Localised across all 8 UI languages with full translations. The full multi-tag taxonomy from the original issue isn't going forward; if demand for it grows past the current 3 thumbs-up the design can layer on top of these fields without breakage. - **Per-event ntfy priority** ([#990](https://github.com/maziggy/bambuddy/issues/990)) — ntfy supports a `Priority` header (1=min, 2=low, 3=default, 4=high, 5=urgent) that drives sound, visibility, and push behaviour on the receiving device, but the existing notifier sent every event at the server default — so a "50% complete" ping looked identical to "print failed" or "printer offline". The Add/Edit Notification modal now renders a per-event "ntfy Priority" section (visible only when the provider type is `ntfy`) listing each *enabled* event with its own Min / Low / Default / High / Urgent dropdown; selections persist into the provider's `config.event_priorities` map and the backend emits a matching `Priority: N` header on the ntfy POST/PUT request (including the image-attachment path). Events not explicitly mapped, malformed values, and out-of-range values (0, 6, "abc", null) all fall through to ntfy's server-side default — there is no clamping, so a misconfigured value never silently sends at the wrong urgency. Test sends (no `event_type` context) deliberately omit the header so the test path cannot accidentally page someone at urgent priority. Existing providers without `event_priorities` are untouched on upgrade. Localised across all 8 UI languages with full translations (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). 6 new backend tests covering header set on mapped event, omitted on unmapped event, omitted when no `event_priorities` configured, omitted when `event_type` is missing, ignored for out-of-range / non-numeric values, and propagated through the image-attachment PUT path. - **Long-lived camera-stream tokens for HA / Frigate / kiosks** ([#1108](https://github.com/maziggy/bambuddy/issues/1108)) — The existing `?token=…` camera-stream tokens expire after 60 minutes which forced home-automation integrations (Home Assistant cards, Frigate, hallway kiosks) to either refresh on a cron or run with auth disabled. New self-service "Camera API Tokens" panel under **Settings → API Keys** (also reachable via the existing settings search box — type "camera token" / "frigate" / "home assistant") lets any user holding `camera:view` mint a long-lived token they can paste once and forget. Revoke uses Bambuddy's standard styled confirmation modal (no `window.confirm` browser default — same pattern as the rest of the app). Tokens are scoped strictly to camera streaming (no privilege escalation surface — no other endpoint accepts them), formatted `bblt_<8-char-prefix>_<32-char-secret>`, and stored as a pbkdf2 hash so even a DB dump can't replay them; the plaintext is shown to the user **exactly once** in a copy-to-clipboard modal (with a `document.execCommand('copy')` fallback for plain-HTTP LAN deployments where `navigator.clipboard` is gated by the secure-context requirement). Hard 365-day max — the issue's `expire_in: 0` (never) is explicitly rejected because an irrevocable infinite token is a footgun-by-design; UI defaults to 90 days, the cap is enforced both client-side (input clamp) and server-side (validation guard). Owners can revoke their own tokens; admins additionally see an "All users" view for leak triage and can revoke anyone's. The `/camera/stream?token=…` auth dependency tries the existing 60-min ephemeral row first (no behaviour change for the common browser case) and falls through to the long-lived path, so the SPA's existing camera flow is unaffected. Indexed `lookup_prefix` keeps verify O(1) per token even on large installs — pbkdf2 only runs against the one candidate row that matches the prefix, never the whole table. New `long_lived_tokens` table (separate from `auth_ephemeral_tokens` because the lifecycle is different — user-owned, named, revocable, hashed; and separate from `api_keys` because that one is for global webhooks with no user FK and a different permission shape). 15 unit tests covering create-validation/scope/expiry rules, verify happy/garbage/expired/revoked/scope-mismatch/prefix-collision paths, list-by-user vs list-all, idempotent revoke; 14 integration tests covering the create-once-then-listing-hides-plaintext contract, the 365-day cap, the auth gate, owner-vs-admin revoke ownership rules, and that the long-lived token verifies through the same camera-stream auth dependency the route uses (and that revoke immediately invalidates it). 6 frontend tests covering list render, empty state, create-then-shown-once flow, days-input clamp, revoke-with-confirm, and revoke-cancelled paths. New `cameraTokens.*` keys across all 8 locales (English fully translated; the seven other locales seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features). - **Tailscale integration for virtual printers** (builds on [#1070](https://github.com/maziggy/bambuddy/issues/1070) by @legend813) — Opt-in per-VP Tailscale toggle brings each virtual printer into the tailnet, so it's reachable from any tailnet device over a private WireGuard tunnel without port forwarding or public exposure. When enabled, Bambuddy provisions a Let's Encrypt cert for the VP's MagicDNS hostname via `tailscale cert` and the MQTT/FTPS listeners serve it. **Slicer-side caveat worth knowing up front:** both Bambu Studio and OrcaSlicer only accept IP addresses (not hostnames) in the Add Printer dialog, so the LE cert's hostname validation doesn't apply — users still need the Bambuddy CA imported into the slicer, same as LAN mode. The practical benefit here is the private tunnel (remote access without DDNS / port forwarding / public exposure), not cert-import elimination. Default is opt-out (toggle off) so users without Tailscale don't see cert-provisioning attempts or log noise. When a user flips the toggle on a host without a working Tailscale binary, the backend returns `409 tailscale_not_available` and the UI reverts + surfaces a specific toast pointing at the setup steps (install Tailscale → `tailscale up` → `tailscale set --operator=` → enable HTTPS in the tailnet admin console). Docker image now ships the `tailscale` CLI pre-installed; users wire up by uncommenting the `/var/run/tailscale/tailscaled.sock` volume mount in `docker-compose.yml`. The MagicDNS hostname is surfaced on the VP card with a copy-to-clipboard button (modern `navigator.clipboard` in secure contexts, `document.execCommand` fallback for plain-HTTP contexts with textarea cleanup in `finally`). Cert renewal runs daily in-process and restarts only the affected VP's TLS listeners. New i18n keys `virtualPrinter.tailscaleDisabled.{title,description}` + `virtualPrinter.toast.{tailscaleNotAvailable,copyFailed}` across all 8 locales with full translations. 3 new backend integration tests for the 409 guard, 2 unit tests for the `_cancel_restart_task` self-await guard, 4 unit tests for the settings-dedupe migration, and 3 new frontend tests for the clipboard fallback path. Thanks to @legend813 for the original opt-out toggle PR that this was built on top of. - **Library Trash Bin + Admin Bulk Purge + Auto-Purge** ([#1008](https://github.com/maziggy/bambuddy/issues/1008)) — Library files now move to a trash bin on delete instead of being hard-deleted from disk, with a configurable retention window (default 30 days) before a background sweeper permanently removes them. Admins get a new "Purge old" action on the File Manager that shows a live preview of count + total size before moving every file older than *N* days (with an opt-in toggle for never-printed files, on by default) into the trash in one shot. A new **Auto-purge** setting in Settings → File Manager runs the same purge automatically on a 24-hour cadence when enabled — files still go to Trash first so the retention window remains the safety net; default-off so existing installs don't surprise anyone. Both the per-user delete flow and the admin bulk purge go through the same trash — regular users see and manage their own trashed files; admins see everyone's. External (linked) files bypass trash and keep the original hard-delete behaviour since their bytes aren't under Bambuddy's control. New `library:purge` permission gates the admin operations; retention is adjustable inline on the Trash page for admins. Adds nullable `deleted_at` column on `library_files` with an index (dialect-aware migration: `DATETIME` on SQLite, `TIMESTAMP` on PostgreSQL, since raw `DATETIME` is SQLite-only syntax); every `LibraryFile` query site now routes through a new `LibraryFile.active()` classmethod so trashed rows can't leak into listings, print dispatch, MakerWorld dedupe, or stats. 17 new backend integration tests + 8 new frontend component/page tests; localised across all 8 UI languages. Thanks to @cadtoolbox for the proposal and the follow-up answers that tightened the spec. - **Archive Auto-Purge** ([#1008](https://github.com/maziggy/bambuddy/issues/1008) follow-up) — Settings → Archives now has an auto-purge toggle plus a **Purge archives now** action on the Archives page header (next to Upload 3MF, mirroring File Manager's placement) that hard-deletes print archives not printed within a configurable window (default 365 days, min 7, max 10 years) with the same live-preview modal as the library purge. Reprinting an archive reuses the row and updates its `completed_at`, so the purge honours the **most recent print completion** — a two-year-old archive you reprinted yesterday is not eligible for deletion. Unlike the library trash, archives are hard-deleted: print history is a decaying timeline, so there is no trash bin intermediate; download or favourite anything you want to keep first. The sweeper runs on the same 15-minute scheduler as the library trash but throttles actual purge runs to once per 24h so a tight tick cadence doesn't churn the DB. Each purged archive goes through the existing safety-checked `ArchiveService.delete_archive` path so the 3MF, thumbnail, timelapse, source 3MF, F3D, and photo folder are all cleaned up together with the DB row. Gated by a new dedicated `archives:purge` permission (Administrators group by default, backfilled on upgrade); 9 new backend integration tests; localised across all 8 UI languages. - **MakerWorld Integration** — Paste any `makerworld.com/models/…` URL on the new MakerWorld sidebar page to pull the full model metadata, plate list, creator/license info, and per-plate images, then one-click **Save** or **Save & Slice in Bambu Studio / OrcaSlicer** per plate. Closes the last workflow gap for LAN-only users who still had to keep the Bambu Handy app installed solely to send MakerWorld models to their printers. Reuses the existing Bambu Cloud login token for download authentication — no separate OAuth flow, no companion browser extension, no cookie paste. `LibraryFile` now tracks `source_type` + `source_url`, so re-importing the same plate dedupes to the existing library entry. Search / browse-catalogue is intentionally out of scope because MakerWorld's public search endpoint isn't reachable from a server-originated request; the URL-paste flow covers the actual discovery pattern (Reddit / YouTube / shared links). **Endpoint route (non-obvious, ~1 day of reverse engineering)** — Pr0zak/YASTL#51 documented that `makerworld.com`-hosted design-service endpoints are cookie-gated (Cloudflare WAF serves a generic "Please log in to download models" to any non-browser bearer request), but the same backend is exposed unblocked at `api.bambulab.com`. The working path turned out to be `GET https://api.bambulab.com/v1/iot-service/api/user/profile/{profileId}?model_id={alphanumericModelId}` with `Authorization: Bearer ` — a different service (`iot-service`, not `design-service`) and a different host, accepting the same bearer the user already signs in with. Response carries a 5-minute-TTL presigned S3 URL (``s3.us-west-2.amazonaws.com/…?at=…&exp=…&key=…``). The `modelId` query param is the alphanumeric identifier (e.g. ``US2bb73b106683e5``) that only appears in the design response body, *not* the integer ``designId`` from the ``/models/{N}`` URL — so the import flow fetches design metadata first, reads `modelId`, then calls iot-service. S3 presigned URLs must be fetched with ``urllib.request`` (not httpx / curl_cffi) because the signature is computed over the exact query-string bytes and any normalising encoder breaks it with ``SignatureDoesNotMatch`` 400s (YASTL#52 describes the same issue). Every other published reverse-engineering project we evaluated (schwarztim/bambu-mcp, kata-kas/MMP) solved the gating by shipping "paste your browser cookie" flows; reusing the existing Bambu Cloud bearer is a substantially cleaner UX and the only fully-automated path. **UI and UX features** — per-plate picker with inline **Save** / **Save & Slice in Bambu Studio / OrcaSlicer** buttons, **Import all** to batch-import every plate sequentially, folder picker on the page (default: auto-created top-level "MakerWorld" folder), image gallery lightbox per plate (keyboard ←/→/Esc), two-column sticky layout with Recent imports sidebar (last 10 MakerWorld imports), per-plate inline follow-up actions after import (**View in File Manager** / **Open in Bambu Studio** / **Open in OrcaSlicer** / **Remove from library**), per-plate delete via the standard Bambuddy confirm modal (no browser `confirm()`), elapsed-time + phase label ("Resolving … 3 s", "Downloading … 18 s") during the synchronous import POST so users see progress on large 3MFs, URL-change detection that drops the preview when the pasted URL diverges from the resolved one (fixes a class of "I thought I was importing model B but got A" dedupe confusion), rich error toasts per-phase, and the slicer-open path reuses Bambuddy's existing token-embedded library download (`/library/files/{id}/dl/{token}/{filename}`) so the handoff works even with auth enabled. Localised across all eight UI languages. **Security hardening** — the MakerWorld description HTML is user-authored and goes through `DOMPurify.sanitize()` before `dangerouslySetInnerHTML`. `` tags inside summaries are rewritten to route through Bambuddy's ``/makerworld/thumbnail`` proxy so the SPA's ``img-src 'self' data: blob:`` CSP stays unwidened. Thumbnail proxy now uses ``follow_redirects=False`` (the host-allowlist guarantee is only meaningful on the initial URL — a 302 to `169.254.169.254` would otherwise bypass it). The 3MF CDN fetch sends only `User-Agent` — the Bambu Cloud bearer is never forwarded to the CDN. S3 presigned-URL fetch uses a `urllib.request` opener with a no-op ``HTTPRedirectHandler`` for the same reason. Filenames from MakerWorld responses are `os.path.basename`'d before persisting, so a malicious ``name: "../../evil.3mf"`` cannot surface a path-traversal string into the DB / UI (on-disk storage uses a UUID filename regardless). New routes respect the `MAKERWORLD_VIEW` (resolve / recent-imports / status) and `MAKERWORLD_IMPORT` (import) permissions. SSRF guard on downloads rejects any host that isn't `makerworld.bblmw.com`, `public-cdn.bblmw.com`, or a `.amazonaws.com` subdomain. **Test coverage** — 46 unit tests for `services/makerworld.py` (header shape, API base, `get_design`/`get_design_instances`/`get_profile`, `get_profile_download` 200/401/403/404/no-token, `download_3mf` SSRF rejection of 4 hostile hosts, S3 path delegation, CDN path with minimal headers, size-cap, `_download_s3_urllib` happy/redirect/size/network paths, `fetch_thumbnail` with `follow_redirects=False`); 19 route tests (`/resolve`, `/import` with folder autocreation + explicit folder + dedupe + filename basename + profile_id response, `/recent-imports` with empty-list / ordering / pydantic shape / limit clamping, `_canonical_url` unit); 12 frontend tests (button labels, slicer-name interpolation, URL-change detection, inline post-import actions, Recent imports rendering, DOMPurify `