# Changelog All notable changes to Bambuddy will be documented in this file. ## [0.2.5b2] - Unreleased ### Fixed - **Queued prints never dispatched to FINISH-state printers when "Require plate-clear confirmation" was disabled (#1865, reporter @PurseChicken)** — On installs that had never explicitly saved the plate-clear setting, the scheduler enforced the plate-clear gate even though the Settings toggle (and the printer card's plate badge/button) correctly showed it as OFF, so a finished printer sat at `awaiting_plate_clear=True` forever with no UI path to clear it and the queue item stuck in "Waiting". **Root cause (pinpointed by the reporter):** two code paths disagreed on the default. `SettingsSchema.require_plate_clear` defaults `False` and the entire frontend treats a missing value as off, but `print_scheduler.check_queue()` read the setting with `_get_bool_setting(..., default=True)` — and `_get_bool_setting` returns its default when no DB row exists. With no settings row, the UI showed the gate off while the scheduler kept it on. Both defaults shipped in the same commit (#752), so this was a latent inconsistency, not a #1625 regression. **Fix:** the scheduler now reads `require_plate_clear` with `default=False`, matching the schema and the UI. Added regression tests pinning the call-site default and the `_get_bool_setting` no-row behavior. - **Light theme: low-contrast washed-out text on status/warning banners and colored badges (#1909, reporter @AntonPalmqvist)** — The app was built dark-first: hundreds of hardcoded Tailwind semantic-color text utilities at light shades (`text-amber-400`, `text-blue-300`, `text-green-200`, etc.) had no `dark:` variant, so with `darkMode: 'class'` they applied in the light theme too — light-amber/blue/green text on pale `bg-*-500/10` tints or white cards, barely readable. The three reporter-cited spots were all this bug: the AMS **Drying** status banner (Printers page), the **Archives** no-3MF-thumbnail warning, and the **System/debug-logging** banner. Fixed app-wide by giving every affected semantic text/icon/tint a theme-aware pair — a readable darker shade in light theme (e.g. `text-amber-700`/`-800`/`-900`) with the original light shade pinned to `dark:` so the dark theme is unchanged — across ~100 files (banners, status pills, permission/role badges, method chips, validation errors, stat-card icons, delete-action hovers, and more). The custom CSS-variable `bambu-*` palette (which already self-corrects per theme) and the SpoolBuddy kiosk (dark-theme-only) were deliberately left untouched. - **Sponsor toast ignored its 14-day cooldown and re-fired on every fresh session (#2477, reporter @pchulpjoost)** — The in-app supporter toast ("You've completed X prints with Bambuddy…") reappeared on essentially every visit instead of respecting the documented 14-day cross-family cooldown. **Root cause:** the backend owns the cooldown, but it only persists the cooldown anchor (`last_shown_at`) and the seen-milestone record inside `POST /sponsor-prompt/dismiss` — and the frontend hook only called `dismiss` from the "View supporters" CTA's `onClick`. A user who *saw* the toast but never clicked the CTA persisted no state at all. The per-tab `sessionStorage` guard hid the re-fire within a single browser session, but every fresh session (new tab, reopened browser, or another device — the reporter confirmed "Other OS/Browser has the same problem") re-ran the check against still-empty backend state and showed the same milestone again. To a user opening Bambuddy to check on a finished print, that looked like "every print." **Fix:** the hook now records the toast as shown the moment it renders — it POSTs `/dismiss` (whose documented job is exactly "anchor the 14-day cooldown and record the milestone as shown") immediately after `showPersistentToast`, instead of waiting for a CTA click. Being *displayed* is what arms the cooldown; clicking the CTA stays optional and just navigates to the sponsors page. `frontend/src/hooks/useSponsorPrompt.ts` only; backend cooldown logic unchanged. - **Windows installer: fresh install fails to start — "connection refused", nothing listening on port 8000 (#2474, reporter @fangme)** — On a clean Windows 10 machine, installing the `.exe` left the Bambuddy service showing "running" in services.msc but the dashboard refused every connection (localhost, IP, and hostname), and `netstat -ano` showed nothing on :8000. **Root cause — bottom of a 100k-line `service-stderr.log` traceback:** `init_db()` → SQLAlchemy async engine → `greenlet_spawn` → `ValueError: the greenlet library is required to use this function. DLL load failed while importing _greenlet: The specified module could not be found.` The 100k repeated `merged_lifespan` frames above it are just FastAPI's nested-lifespan stack unwinding — noise. The real failure: greenlet's `_greenlet.pyd` couldn't load, SQLAlchemy's async engine couldn't start, the FastAPI lifespan raised, and uvicorn **never bound the port** — so the supervised process stays up (NSSM sees it running) while the app itself has crashed on startup. **Why greenlet specifically:** the Win32 error 126 ("The specified module could not be found") on a `.pyd` that pip installed successfully means the module is present but a *dependency DLL* is missing. greenlet's extension is **C++** and needs `vcruntime140_1.dll` (table-based exception handling); the python.org **embeddable** distribution the installer bundles ships `vcruntime140.dll` but **not** `vcruntime140_1.dll`. `python313.dll` is pure C and only needs the former, which is exactly why python.exe starts and runs all the way to `init_db` before greenlet is the first thing to need the missing C++ runtime. On machines that already have the VC++ 2015-2022 redistributable installed (the maintainer's box, CI runners) `vcruntime140_1.dll` is in System32 and everything works — masking the bug until a truly fresh Win10 box hit it. **Fix:** the installer build now ships the C++ runtime app-locally — `vcruntime140_1.dll` and `msvcp140.dll` are staged next to `python.exe` (where `vcruntime140.dll` already lives), sourced from a vendored copy if present or the build runner's System32 otherwise, failing the build loudly if neither has them. `installers/windows/build.py` only; the Inno Setup `[Files]` step already copies `staging\python\*` recursively so the new DLLs are packaged automatically. **Workaround for the current build:** install the "Microsoft Visual C++ 2015-2022 Redistributable (x64)" from Microsoft, then restart the Bambuddy service — that puts `vcruntime140_1.dll` in System32 where the embedded Python finds it. - **Virtual Printer "bind interface" dropdown is empty on macOS** — Adding a Virtual Printer on macOS showed no interfaces to bind to. `get_network_interfaces()` only routed Windows to the cross-platform psutil path; macOS fell into the Linux branch, which uses the Linux-only `SIOCGIFADDR`/`SIOCGIFNETMASK` ioctls (`0x8915`/`0x891B`). macOS/BSD have `fcntl` but different ioctl numbers and sockaddr layout, so every per-interface ioctl raised `OSError` and the function silently returned an empty list (and `get_all_interface_ips()`, which has no `ip` binary to fall back to on macOS, inherited the empty result). Interface enumeration now routes **all** non-Linux platforms (macOS, BSD, Windows) through psutil, which returns each interface's name + IPv4 + netmask and filters loopback/link-local/down adapters while keeping real LAN and VPN (utun/Tailscale) interfaces bindable. Linux keeps its existing ioctl path unchanged. - **Native install script fails on macOS with Homebrew/venv permission errors** — On macOS the installer mixed root-only steps (defaulting to `/opt/bambuddy`, which nudged users into `sudo ./install.sh`) with steps that must **not** run as root: `brew install` hard-refuses to run as root (aborting the script mid-way), and any venv / `node_modules` created by root can't be managed by the launchd agent (which runs as the user), producing permission errors on `pip`/`npm`. The macOS path is now fully rootless: the script refuses to run under `sudo` on macOS with an actionable message, defaults the install directory to `~/bambuddy` (user-owned, no `/opt` write), and the download/venv/frontend/env/directory steps no longer shell out to `sudo` on macOS. A custom `--path` under a root-owned parent still works — the script elevates only to create+chown that one directory to the user, then continues rootless. Linux behaviour (service user + systemd) is unchanged. - **External camera "connection lost" when the snapshot URL serves a non-JPEG image (#1902)** — An external camera configured in HTTP-snapshot mode failed to load in Bambuddy with a repeating "connection lost", even though the camera URL rendered fine when opened directly in a browser. The log showed `Snapshot does not appear to be JPEG` on every polled frame followed by the stream ending. Root cause: `_capture_snapshot` returned the fetched bytes even when they weren't JPEG, and the MJPEG stream wraps every part with a hard-coded `Content-Type: image/jpeg` boundary — so a camera serving PNG/WebP/BMP stills (common on IP cameras and reverse-proxied snapshot endpoints) sent the browser a non-JPEG payload labelled as JPEG, which the browser rejected, tearing down the whole `multipart/x-mixed-replace` stream. `_capture_snapshot` now transcodes non-JPEG stills to JPEG (via OpenCV, already a dependency) before streaming; genuine JPEG snapshots keep their byte-for-byte fast path, and truly undecodable responses (HTML error pages, auth redirects) fall back to the previous raw-return behaviour with a clearer one-off warning instead of a per-frame log flood. Fixes browser playback and keeps the JPEG-only downstream (plate detection, Obico, finish photo) working for these cameras. - **Per-user Notifications page unreachable from the sidebar (#1901, reporter @JmanB52D)** — The Notifications entry (where each user opts in/out of their own print email notifications) disappeared from the left navigation. The page (`/notifications`) and its API were both intact — only the sidebar link was gone, so the screen was reachable only by typing the URL. Root cause: the sidebar-ordering refactor in #1673 accidentally deleted the `notifications` item from `defaultNavItems` (and its `notifications:user_email` permission mapping) while extracting the ordering helpers, but left the advanced-auth visibility gate that references that id — so the gate had nothing to gate and the item could never render. Restored both the `defaultNavItems` entry and the permission gate; the item now shows for any user holding `notifications:user_email` (both default groups, Administrators and Operators, do) when advanced auth and user email notifications are enabled, exactly as before #1673. - **Virtual Printer FTP uploads silently truncated under uvloop — a corrupt `.gcode.3mf` was archived, queued, and forwarded to the real printer with a `226 Transfer complete` (#1896, reporter @dj-oyu)** — On a native venv install (not Docker), slicing in Bambu Studio and sending to a queue-mode VP produced a truncated upload: Bambuddy logged `226 Transfer complete`, archived the file, added it to the queue, and later pushed the corrupt file to the physical printer (A1 mini), which then failed to parse/start the job. Every truncated file ended at an exact multiple of 4096 bytes with a valid `PK\x03\x04` local header but no ZIP End-Of-Central-Directory record. **Root cause — isolated deterministically by the reporter.** uvloop's SSL layer discards already-received but still-buffered data when the client closes the data connection **without a TLS `close_notify`** (a "ragged EOF") while the reader is **flow-control-paused** (slow consumer). `cmd_STOR` writes each 64 KiB chunk to disk synchronously inside the read loop; on slow storage (the reporter's data dir was on a microSD on an ARM64 SBC) the reader falls behind, the transport pauses, and the tail of the upload is lost — `read()` then returns a clean empty EOF, so the loop exits normally with **no exception and no write error**, and the server acks 226 for a file it truncated itself. The reporter's isolation matrix reproduces it on a minimal uvloop 0.22.1 TLS server (slow reader → 2,248,704 of 2,500,001 bytes, 3/3 runs) but never on CPython's default asyncio loop or on uvloop with a fast reader — which is why Docker/x86-with-SSD deployments almost never hit it (the reader keeps up, flow control never pauses, the loss window never opens). Bambuddy's Dockerfile already runs `--loop asyncio`; **every native launch path did not** and so auto-selected uvloop via `uvicorn[standard]`. **Fix — two independent layers.** (1) *Remove the trigger.* Added `--loop asyncio` to every native launch path so uploads actually arrive intact, matching the Dockerfile: `deploy/bambuddy.service`, `install/install.sh` (systemd unit + macOS launchd plist), `spoolbuddy/install/install.sh` (the bundled Bambuddy backend service), `installers/windows/service/install-service.bat` (NSSM), `README.md`, and the wiki install docs (run command, systemd, launchd) — each with an inline "do not remove, see #1896" note. The reporter verified `--loop asyncio` fully resolves it (before: 8/8 real Bambu Studio uploads truncated; after: 2/2 intact, valid ZIPs, `testzip` clean). (2) *Defense in depth, loop-independent.* `cmd_STOR` now validates that a received `.3mf` opens as a ZIP (reads the central directory — O(dir), no decompression) **before** replying 226. A truncated/corrupt 3MF is treated exactly like a failed transfer: the file is dropped and the slicer gets `426 Transfer failed: uploaded 3MF is incomplete or corrupt`, and the `on_file_received` callback that archives/queues/forwards the job never runs — so a broken upload surfaces as an immediate, actionable slicer-side send error instead of a confusing printer-side parse failure later. Validation is scoped to `.3mf` uploads; other filetypes keep the prior pass-through behaviour. This layer protects anyone who still runs uvloop for any reason (custom launch command, future `uvicorn[standard]` default). **Tests.** `test_vp_ftp_stor.py`: the happy-path test now feeds a real multi-chunk ZIP and asserts 226 (not 426); new `test_stor_rejects_truncated_3mf` drops the EOCD-bearing tail and asserts 426 + file removed + `on_file_received` never called; new `test_stor_skips_zip_validation_for_non_3mf` asserts a plain `.gcode` still gets 226 (no false-positive). 6/6 in the file green, ruff clean. **Scope.** Backend (one validation block in `cmd_STOR` + `zipfile` import) plus launch-config across repo installers, the Windows/SpoolBuddy installers, README, and the install wiki. No DB migration, no new permission, no i18n key, no frontend change. Users on a native install: after upgrade, re-run the installer (or add `--loop asyncio` to your existing service command) to stop the truncation at the source; the ZIP-validation guard takes effect on the next Bambuddy restart regardless. **Workaround for older versions:** launch uvicorn with `--loop asyncio`. - **API keys could not manage Projects — every project mutation returned `403 "API keys cannot be used for administrative operations"` regardless of the key's granted permissions (#1893, reporter @abbasegbeyemi)** — `POST /projects/{id}/add-archives`, project create/update/delete, and every other project mutation route was unreachable for any API key. **Root cause.** `PROJECTS_CREATE` / `PROJECTS_UPDATE` / `PROJECTS_DELETE` were in `_APIKEY_DENIED_PERMISSIONS` in `core/auth.py` with no corresponding entry in `_APIKEY_SCOPE_BY_PERMISSION` and no `can_manage_projects` flag on `api_keys` at all — so under the GHSA-r2qv allowlist model they resolved to scope `None` and raised the generic administrative-operations 403. This is the exact regression class already fixed for archives (#1888) and library (#1832): the projects block sat directly between the comment blocks documenting those two carve-outs but was never itself carved out. **Fix.** New per-key scope `can_manage_projects` (column on `api_keys`, DEFAULT TRUE for keys created via the UI going forward; existing rows backfill to FALSE so the upgrade path never silently widens scope — these permissions were explicitly denied for every key before, so nothing relies on them). Unlike archives/library, the project routes gate on plain `RequirePermissionIfAuthEnabled(Permission.PROJECTS_*)` — there is no OWN/ALL ownership split for projects — so all three CRUD permissions map directly to the one scope. Project **membership** edits (`add_archives_to_project` etc.) gate on `PROJECTS_UPDATE`, so they're covered by the same toggle; `PROJECTS_READ` is unchanged (already under `can_read_status`, so API keys could always read projects). Users opt a key in from Settings → API Keys ("Manage Projects" toggle, with a "Projects" badge on the key list). The bundled SpoolBuddy kiosk key (created via the CLI) is set to `can_manage_projects=False` to stay minimally scoped. **Migration** is dialect-agnostic (`BOOLEAN` is valid on both SQLite and Postgres); verified end-to-end on a throwaway fresh SQLite and Postgres 17 that the column adds, legacy rows backfill to FALSE, and a new row defaults to TRUE. **Tests.** `test_auth_apikey_rbac.py` extended: the `_check_apikey_permissions` scope matrix now covers all three project permissions (true→allow, false→403, no cross-scope leakage), and `PROJECTS_CREATE` / `_UPDATE` / `_DELETE` added to the operational-allowed drift guard + threaded through the structural allowlist/flag-parity checks — 63 cases green. **Scope.** Backend (model + migration + allowlist + schema + route + CLI) plus the Settings API-key UI (toggle + badge + type) and 11-locale i18n for the new label/description/badge. No change to the project routes themselves — they already gated on the right permissions; only the API-key classification of those permissions was wrong. - **Auto-drying stopped a manually started AMS drying cycle after exactly 30 minutes, cutting long PETG/PA dries short (#1892, reporter @Spionkiller01)** — With ambient/queue auto-drying enabled, starting a drying cycle *manually on the printer* (or a cycle that survived a Bambuddy restart) got a stop-drying MQTT command ~30 minutes in, every time — killing an intended 8-12 h cycle. The reporter had Bambu Lab support analyse the printer logs, which confirmed an external tool issued the stop; that tool was Bambuddy. **Root cause — two defects compounding in `_check_auto_drying()` (`backend/app/services/print_scheduler.py`).** (1) The already-drying branch carried the comment *"Drying we didn't start (manual or from before restart) — track but don't stop"* but the very next lines applied the humidity-based auto-stop to it anyway; a manually started dry was treated identically to a Bambuddy-initiated one. (2) The humidity re-check is fundamentally unreliable: relative humidity drops steeply in heated air, so the AMS sensor reads ~15-20% within minutes of the dryer starting even while the filament is still saturated (the reporter's log shows 18%). So `humidity <= threshold` is effectively *always true* once drying runs, and the only thing delaying the stop was the `_min_drying_seconds = 1800` floor — which is why the kill landed at exactly the 30-minute mark. This second defect also silently truncated Bambuddy's **own** preset-duration dries (e.g. a PETG 8 h cycle) to ~30 min, not just manual ones. **Fix.** Removed the humidity-based early-stop entirely — a running drying cycle is now left to run to its configured duration, which the firmware stops when the duration elapses. This is simpler and more correct than exempting only manual dries (the reporter's suggested `_manual_drying` set), because the humidity re-check can't distinguish "filament is dry" from "air is hot" for *any* cycle, so it never did its intended job — it just always fired at the floor. Scheduling-driven stops are unaffected and still work through `_stop_drying()`: a print taking priority, or queue-mode no longer needing the dry, still stops it. The now-unused `_min_drying_seconds` attribute was removed. Bambuddy still *starts* auto-drying on the same humidity-over-threshold trigger; only the mid-cycle humidity re-stop is gone. **Tests.** `test_scheduler_auto_drying.py` updated: `TestMinimumDryingTime` now pins the #1892 contract (a running dry is never stopped by a humidity re-check — before or long after the old floor, including when humidity reads low), and `TestBlockForDryingBugFix` asserts an already-running dry in block mode is left alone (block mode still gates *new* starts on printers with pending items). 51/51 in the file green, ruff clean. **Scope.** Backend-only, one branch simplified in `_check_auto_drying` plus the attribute removal. No DB migration, no new permission, no i18n key, no frontend change. Users on 0.2.5b1 and earlier: the fix takes effect on the next Bambuddy restart. **Workaround for older versions:** disable ambient/queue auto-drying in Settings before starting a manual drying cycle. - **When auth was enabled, a browser that could not mint a WebSocket token retried forever, hammering `POST /api/v1/auth/ws-token` every 3 seconds (reported by corporate sponsor)** — After the GHSA-r2qv hardening (`b7d7c825`), `/api/v1/ws` requires a token from `POST /api/v1/auth/ws-token` (gated by `Permission.WEBSOCKET_CONNECT`). If that mint failed, `useWebSocket` swallowed the error and **fell through to open a tokenless socket anyway**; the server closed it with code `4401`, and `ws.onclose` unconditionally scheduled a reconnect 3 s later — an endless loop that spammed the auth endpoint with `401`/`403`s. The dominant trigger was a *validly logged-in* user whose group lacks `WEBSOCKET_CONNECT` (e.g. a custom lower-privilege "ops" group): the mint returns `403` and the loop never ends. A secondary leak: on logout the provider unmounts, but the `close()`-triggered `onclose` could still schedule one more post-unmount reconnect. **Fix.** The token-mint failure is now classified: a `401` (JWT expired — `request()` already clears it and dispatches `auth:expired`, redirecting to `/login`) or `403` (valid session, missing permission — stays logged in, live updates degrade to the existing REST polling) **stops** the hook — no tokenless socket, no reconnect. A `4401` close is now treated as terminal (reconnecting can't fix an auth rejection without a fresh login, which remounts the provider). Network/`5xx` errors still reconnect as before. A `disposedRef` set in the effect cleanup before `close()` prevents the unmount-race reconnect. The same 401/403 no-open guard was applied to `StreamOverlayPage` (which has no reconnect loop, so it was one doomed socket per mount rather than a spin). **Tests.** New `useWebSocket` cases: a `4401` close does not reconnect, a `403` mint opens no socket and does not loop, and a close firing during unmount schedules no reconnect. **Note.** Auto-granting `WEBSOCKET_CONNECT` to lesser groups was rejected on purpose — the WebSocket streams all printer status, so that would partly undo the GHSA-r2qv gate; graceful degradation is the correct behavior. The group editor now shows a one-line hint under the WebSocket permission explaining that live updates require it (falls back to polling otherwise), translated across all 11 locales. - **A transient error while validating a stored login on page load could silently discard a valid "Remember Me" token, forcing a re-login (#1889, reporter @superdong69)** — On mount, `AuthContext.checkAuthStatus` restores the persisted token from `localStorage` and validates it via `GET /auth/me`. The `catch` around that call cleared the token on **any** failure — not just a definitive `401` invalid-token — so a brief backend-not-ready-yet or reverse-proxy hiccup (plausible right after a container restart, e.g. on Unraid) would delete a still-valid token. Because the token was *deleted*, a subsequent reload couldn't recover it either; the user was bounced to the login screen. **Fix.** Token validation now retries transient failures (up to 3 attempts with short backoff) and only discards the token on a definitive `401` invalid-token response — which `request()` already handles (it clears the token and dispatches `auth:expired`). Transient/`5xx`/network errors leave the persisted token intact so the session survives a slow load and a reload can recover. Note "Remember Me" remains client-storage only (localStorage vs sessionStorage); it does not extend the server-side JWT lifetime, which stays governed by `session_max_hours` (default 24h). **Tests.** New `AuthContext` cases: `/auth/me` failing transiently keeps the persisted token (no forced re-login), a definitive `401` clears it, and a valid token loads the user. **Note.** This hardens a real latent bug; if a specific report still recurs, the likely remaining cause is the browser clearing site data / DOM storage on close (e.g. Firefox "Delete cookies and site data when Firefox is closed", strict tracking protection, or a private window), which no server-side change can prevent. - **Auto power-off cuts power mid-print when a failed print is restarted from the printer's touchscreen, and ignored the plug's configured cooldown setting (#1890, reporter @owlery7)** — With "auto power off after finish" enabled, a print that failed and was then reprinted from the printer's finish screen got its power cut ~10 minutes in. **Root cause — two defects.** (1) There were two parallel auto-off systems. The correct one (`SmartPlugManager.on_print_complete`) honours each plug's configured off strategy (`off_delay_mode`: a time delay of `off_delay_minutes`, or a temperature threshold of `off_temp_threshold`), registers a *cancellable* task, and only fires on success. But the print-queue "auto off after this job" trigger used a **second, inline implementation** in three places (`main.py`, `print_scheduler.py`, `print_queue.py`) that hardcoded `wait_for_cooldown(target_temp=50°C, timeout=600s)` — ignoring the plug's `off_delay_mode`/`off_delay_minutes`/`off_temp_threshold` entirely — and, critically, **ignored the return value**: `wait_for_cooldown` returns `False` on its 600s timeout, but the caller powered off anyway. In the reporter's timeline: 04:02:14 the print failed → inline auto-off scheduled a 600s cooldown wait; 04:03:35 the user reprinted from the touchscreen (nozzle reheated, so it never reached 50°C); 04:12:14 the wait hit its 600s timeout, returned `False`, and the code cut power into the running reprint. (2) These inline tasks were fire-and-forget (`spawn_background_task`) with no cancellation, so a new print couldn't abort a pending off; and even the correct system's `on_print_start` cancellation was gated behind `auto_on`, so a plug with auto-on disabled kept its pending off. **Fix.** All three queue/scheduler auto-off triggers now delegate to a single new `SmartPlugManager.schedule_off_after_queue_job`, which schedules via each plug's configured strategy (shared with `on_print_complete` through a new `_schedule_off_per_mode` helper) — so the per-plug time-delay / temperature-threshold setting is finally honoured instead of a hardcoded 50°C/600s. Because these route through `_pending_off`, a reprint cancels them for free. A new `printer_manager.is_print_active()` guard (state ∈ {RUNNING, PAUSE, PREPARE, SLICING}) is checked immediately before the actual power-off in every executor — `_delayed_off` (fires unconditionally after N minutes), `_temp_based_off` (can trip during a reprint's PREPARE/heat phase), and the startup `resume_pending_auto_offs` time-mode immediate-off (a restart while an off was pending would otherwise power off a print that started during the downtime) — so no path cuts power on a loaded print; a stale pending off is cleared instead. The `on_print_start` cancellation was moved ahead of the `auto_on` gate so a reprint aborts a pending off regardless of that setting. The queue override still fires on failure (preserving intent) but is now protected by the active-print guard + cancellation. **Verification.** Reproduced the reporter's timeline end-to-end: the off now schedules with the plug's 5-min delay (300s, not 600s) and the reprint survives with no power cut. **Tests.** New `TestActivePrintGuard` in `test_smart_plug_manager.py` (delayed-off skips while printing / offs when idle; temp-off defers while printing / offs when cool+idle; queue-off uses the plug's time/temp settings regardless of global `auto_off`; skips disabled + HA-script plugs; reprint cancels a pending off even with `auto_on` disabled), a resume-on-restart guard test (stale pending off skipped + cleared while printing), plus an `is_print_active` state matrix in `test_printer_manager.py`. **Scope.** Backend only; consolidated three hardcoded copies into one settings-aware path (net code reduction). `wait_for_cooldown` is retained (correct, still unit-tested) but no longer used by the auto-off paths. - **Deleting a print from the archive fails with HTTP 403 for any request authenticated with an API key (#1888, reporter @MartinNYHC)** — `DELETE /api/v1/archives/{id}` (and the archive edit routes) rejected every API key with `{"detail":"API keys cannot be used for administrative operations"}`, regardless of the print's owner or the key's scopes, so automations that prune old prints were dead on arrival. **Root cause.** The delete route gates on `require_ownership_permission(ARCHIVES_DELETE_ALL, ARCHIVES_DELETE_OWN)`. Both permissions were in `_APIKEY_DENIED_PERMISSIONS` and absent from the `_APIKEY_SCOPE_BY_PERMISSION` allowlist in `core/auth.py`, so for an API key `_check_apikey_permissions` resolved each to scope `None` and raised the generic administrative-operations 403 — the entire archive-management surface (create / update / delete) was unreachable for API keys. This is the same regression class as the #1832 library/maintenance carve-outs, where splitting a management surface's OWN/ALL permissions across allowlist and denylist made it unreachable via `require_ownership_permission` (which gates on the ALL permission, and API keys have no per-row ownership identity). **Fix.** New per-key scope `can_manage_archives` (column on `api_keys`, DEFAULT TRUE for keys created via the UI going forward; existing rows backfill to FALSE so the upgrade path never silently widens scope — these permissions were explicitly denied for every key before, so nothing relies on them). `ARCHIVES_CREATE` / `ARCHIVES_UPDATE_OWN` / `ARCHIVES_UPDATE_ALL` / `ARCHIVES_DELETE_OWN` / `ARCHIVES_DELETE_ALL` moved from the denylist to the allowlist under this scope; OWN and ALL both map to it (matching `can_manage_library` / `can_queue`). `ARCHIVES_PURGE` stays admin-only — it drops the print's contribution to Quick Stats, mirroring `LIBRARY_PURGE`. `ARCHIVES_REPRINT_*` is unchanged (stays under `can_queue`). Users opt a key in from Settings → API Keys ("Manage Archives" toggle, with an "Archives" badge on the key list). The bundled SpoolBuddy kiosk key (created via the CLI) is set to `can_manage_archives=False` to stay minimally scoped. Migration is dialect-agnostic (`BOOLEAN` is valid on both SQLite and Postgres); verified end-to-end on fresh SQLite and Postgres 17 that the column adds, legacy rows backfill to FALSE, and the `column_existed` guard preserves a user's opt-in on subsequent restarts. **Tests.** `test_auth_apikey_rbac.py` extended: the `_check_apikey_permissions` scope matrix now covers all five archive management permissions (true→allow, false→403, no cross-scope leakage), `ARCHIVES_PURGE` added to the admin-denied matrix, `ARCHIVES_DELETE_ALL` / `ARCHIVES_UPDATE_ALL` added to the operational-allowed drift guard, and `can_manage_archives` threaded through the structural allowlist/flag-parity checks — 59 cases green. **Scope.** Backend (model + migration + allowlist + schema + route + CLI) plus the Settings API-key UI (toggle + badge + type) and 11-locale i18n for the new label/description/badge. No change to the archive routes themselves — they already gated on the right ownership permissions; only the API-key classification of those permissions was wrong. - **Server-side slice of a PLA-model / PVA-support 3MF silently loses the PVA — supports print in PLA, archive card hides the PVA tag (#1881, reporter @JonasFovea)** — Reporter uploaded a multi-material H2D Pro 3MF configured with PLA in slot 1 and PVA as support material in slot 2, hit Slice, picked a profile for both slots in the SliceModal. Result: the resulting `.gcode.3mf` carried a single filament (PLA) with no visible supports in the 3D preview, and the archive card never displayed the PVA badge for the source 3MF either. Local BambuStudio slice of the identical file with default settings worked. **Three distinct bugs on the same happy path**, discovered in sequence as each fix uncovered the next. **Bug A — `substitute_unused_plate_filaments` overwrites support-material slots.** The frontend does receive both filaments (log line `get_filament_info called with 4 IDs: ['GFA01', 'GFA00', 'GFG02', 'GFU00']`; `GFU` is Bambu's PVA prefix), the SliceModal renders two dropdowns via `extract_project_filaments_from_3mf`, the user picks a PVA profile for slot 2 and the payload arrives at `_run_slicer_with_fallback` intact. Before the sidecar call, `_run_slicer_with_fallback` (`backend/app/api/routes/library.py:3521`) invokes `substitute_unused_plate_filaments` — the helper that replaces "not used by this plate" slot entries with slot 1's profile so BambuStudio's loaded-filament temperature-spread validator doesn't reject the job (`temperature difference of the filaments used is too large`, exit 194). That helper delegates to `extract_plate_extruder_set_from_3mf` (`backend/app/utils/threemf_tools.py:878`) to enumerate which slots the plate actually references. The extractor walks three sources — `` top-level `extruder` metadata, per-`` overrides, and `paint_color` triangle quadtree leaves — all **object-geometry-derived**. **Support material is a process setting**, not object geometry: BambuStudio writes `support_filament` / `support_interface_filament` / `enable_support` into `Metadata/project_settings.config` and the slicer's process pass generates the support paths at slice time. The three geometry sources return `{1}`, `substitute_unused_plate_filaments` sees slot 2 as "unused", overwrites the user's PVA profile with slot 1's PLA profile, and the sidecar gets `[PLA_json, PLA_json]`. Silent, no error, no warning, no log line. **Bug B — `_extract_filament_info` strips support filaments from the tag list.** Independent from A: `backend/app/services/archive.py:377` was reading `filament_is_support` and excluding any slot where the flag was `"1"` from `filament_type` / `filament_color`. That's what drives the ArchiveCard's material badges. Result: even a correctly-sliced PLA+PVA `.gcode.3mf` would show only "PLA Basic" on the card. **Bug C — process preset overrides source's `enable_support`, `support_filament`, `support_interface_filament`, `support_type`.** Uncovered on the first test slice after A + B shipped: the sliced archive still had only PLA. Comparing `project_settings.config` in the source vs the sliced output: source has `enable_support: '1'` + `support_interface_filament: '2'`, sliced output has `enable_support: 0` + `support_interface_filament: 0`. Bambuddy passes the picked process preset via BambuStudio CLI's `--load-settings`, which is authoritative — every field in the loaded JSON overrides the source 3MF's embedded `project_settings.config`. Bambu's shipped process presets ("0.20mm Standard @BBL H2D" etc.) ship `enable_support: 0` because supports are a per-print decision, not a per-quality one. So even with the substitute-fix (A) sending both filaments to the CLI, `slice_info.config` in the output shows `support_used="false"` and only one `` entry consumed — PLA. The PVA slot loads but never gets referenced. **This inverts BambuStudio GUI's semantics** where the loaded project's settings are authoritative and the process preset is the inheritance backbone — but Bambuddy's `--load-settings` flow has preset winning over project, so any per-project setting the user configured before exporting the 3MF (supports on, PVA-for-interface, etc.) gets discarded on re-slice. **Fix A.** New helper `extract_support_filament_slots_from_3mf(zf)` in `threemf_tools.py` reads `enable_support` / `support_filament` / `support_interface_filament` from `project_settings.config` and returns the set of slots that must stay "used" for a plate print. Gated on `enable_support` (accepts BambuStudio's stringly-typed `"1"`/`"0"`, real booleans, and empty falsy variants); slot value `0` means "same as model" and is ignored; slot value `> 0` is added to the set. `substitute_unused_plate_filaments` unions this into the geometry-derived set — so a support-only slot is now correctly seen as "used" and the user's PVA profile survives to the slicer. When supports are DISABLED (or `support_filament == 0`), the substitution still runs and homogenises the loaded-filament array, preserving the pre-existing #1493 fix for the temperature-spread validator. **Fix B.** Removed the `filament_is_support` filter from `_extract_filament_info`. All configured filament types (PLA, PVA, etc.) now land on `filament_type` / `filament_color` in slot order with dedup on repeats. Sliced `.gcode.3mf` files are unaffected because they promote `_slice_filament_type` (parsed from `slice_info.config` which lists what the print actually consumed) over the project-settings fallback at `archive.py:150-155`; the filter change only affects unsliced source 3MFs that a user uploaded to the Archives page. **Fix C.** New helper `_patch_process_support_settings(process_json, source_3mf_bytes)` reads the source's `project_settings.config` and overlays four support-related fields onto the picked process preset JSON before `--load-settings` sees it: `enable_support`, `support_filament`, `support_interface_filament`, `support_type`. Deliberately targeted — the scope is what fixes #1881 without opening the semantic can of "should every project setting override every preset field" (which would need to reconcile #1201's sentinel handling, all the bed-type / prime-tower / brim / raft edge cases, and users who deliberately pick a preset to escape a broken 3MF's settings). Widening to more fields as follow-up if more "preserve X" reports come in. Silently no-ops on STL / STEP (no `project_settings.config`), on malformed sources, and on malformed presets — the slice then runs with the preset's own defaults, matching pre-fix behaviour on those paths. **Verification.** Ran the patch against the reporter's actual 3MF from Bambuddy's live DB (archive #262): source has `enable_support: '1'` + `support_interface_filament: '2'` + `support_type: 'normal(manual)'`, patched preset gets those exact values while its `layer_height: '0.20'` stays untouched. **Tests.** Nine new cases in `test_threemf_tools.py::TestExtractSupportFilamentSlotsFrom3mf` pin the helper (headline PLA+PVA scenario, distinct body/interface slots, `enable_support` off, slot-0 == same-as-model, JSON bool `enable_support`, integer vs string slot values, missing project settings, malformed JSON, non-numeric slot). Two new cases in `TestSubstituteUnusedPlateFilaments` end-to-end the substitution — `test_support_material_slot_preserved` reproduces the reporter's exact model_settings+project_settings shape and asserts slot 2's `pva_support.json` is NOT overwritten; `test_support_disabled_still_substitutes_unused` is the regression guard that turning supports off preserves the pre-existing homogenisation behaviour. Three new cases in `test_archive_service.py::TestThreeMFParserSupportMaterial` cover Bug B — reporter's PLA+PVA source ships both types + both colours to `filament_type`/`filament_color`, single-support-only degenerate case, duplicate types deduped while duplicate colours kept for the multi-colour path. New `test_slice_process_support_patch.py::TestPatchProcessSupportSettings` (9 cases) pins Fix C: reporter's exact scenario (source wins, layer_height preserved), source-off beats preset-on (symmetric direction), partial-source only patches keys it defines, no-project-settings passthrough, malformed source passthrough, malformed project-JSON passthrough, non-dict project settings passthrough, malformed preset passthrough, non-dict preset passthrough. 337/337 across all affected test files green, ruff clean. **Scope.** Backend-only. One new helper in `threemf_tools.py` (~40 LOC), one 3-line union in `slicer_3mf_convert.py`, one function body simplification in `archive.py`, one new helper in `library.py` (~35 LOC), one call-site in `_run_slicer_with_fallback`. No DB migration, no new permission, no i18n key, no frontend change. Users on 0.2.5b1 and earlier who tried server-side slicing a multi-material PVA-support 3MF: the slice will now actually include PVA supports on the next attempt after upgrade. Users who uploaded source 3MFs with PVA-for-support: the archive card will show both materials after a Bambuddy restart (badges are derived at parse time, so re-uploading refreshes the display; existing rows keep whatever they parsed with). - **Bambu Studio on macOS won't reconnect to the VP after machine sleep — zombie writer pinned in `_clients` for hours (#1872, reporter @avvidme)** — Reporter on H2C + macOS 26.5.1 + BS 2.8.0.50: after every sleep/wake cycle Bambu Studio couldn't see the VP or connect to it. Only workaround was quitting BS and rebooting Bambuddy. The physical printer's own cloud / LAN link recovered in ~5 s from the same sleep, so the delta is in the VP's session-handling. **Log evidence (`bug-report-assets/logs/ddf1ede75df045cd94ad223d0f08f88a.log`).** 14:04:06 shows a healthy `1Hz status push: 60 pushes/min to [IP]:54698` — full-rate 1 Hz for the last minute pre-sleep. Then 5 min of SSDP output only — no push summary for :54698, no OSError, no disconnect line. At 14:09:16 a brand-new TCP source port :54861 connects, authenticates, subscribes — so the MQTT server is not rejecting reconnects. At 14:10:17, the first DEBUG line after the reporter enabled debug logging is `MQTT drain timeout for device/…/report — client may be busy` — smoking gun. **Root cause.** `_publish_to_report` at `mqtt_server.py:1149-1152` caught `asyncio.wait_for(writer.drain(), timeout=5)` `TimeoutError` at DEBUG and returned silently. Timeouts are not `OSError`, so the push loop's `except OSError` at `:441` never saw them — the client sat in `self._clients` until the OS's default TCP keepalive detected the dead peer, which on Linux is `tcp_keepalive_time=7200 s` (2 h) + 9 probes × 75 s = ~2 h 11 min. That's exactly the 5 min silence in the log; drain likely stayed under the 5 s ceiling because the kernel TX buffer had room, so the DEBUG line didn't even fire until much later. Meanwhile the loop was iterating with a stalled client sitting in the dict every tick. **Fix — two hunks.** (1) On drain `TimeoutError`, close the writer (best-effort, catch `Exception` so an already-broken writer doesn't mask the raise) and raise `BrokenPipeError`. The eviction path is indirect but reliable: `BrokenPipeError` is an `OSError` subclass, so it's caught by every `_send_*` wrapper's outer `except OSError` at `:1036` / `:1118` / `:1236` and logged at ERROR — push_counts increments on this tick, no direct eviction. BUT `writer.close()` was called inside `_publish_to_report`, so on the next push-loop tick, `writer.is_closing()` at `:431` returns True → the client is appended to `disconnected` → popped from `_clients`, `_client_serials`, `push_counts` on the same tick. Real eviction latency: ~1 s (one 1 Hz tick), down from ~2 h. Same mechanism as the pre-existing hard-disconnect path (RST → OSError swallowed in `_send_*` → transport marks closing → next-tick eviction), just extended to also cover the "silent stall" case that has no OS-level RST. (2) Tighten the Linux TCP-keepalive schedule right after `SO_KEEPALIVE=1` at `:600`: `TCP_KEEPIDLE=60`, `TCP_KEEPINTVL=15`, `TCP_KEEPCNT=4` — dead-peer detection in ~2 min instead of ~2 h. `getattr(socket, ...)` guards keep the code cross-platform: macOS has `TCP_KEEPINTVL` but not `TCP_KEEPIDLE` (it exposes `TCP_KEEPALIVE` under a different constant), other platforms silently skip whichever knobs their kernel doesn't expose. **What I initially got wrong.** First-pass hypothesis was "no MQTT session takeover on same `client_id`". Wrong. `_handle_connect` at `:762` parses the protocol client_id but discards it (assignment commented out), and `self._clients` is keyed on socket peer `f"{addr[0]}:{addr[1]}"`, so each reconnect gets a distinct key — no takeover race actually exists. The log fixed this: the "not seen" symptom was BS-side (macOS UDP receive socket recovering slowly from sleep, plus BS's `client_id` still holding the old socket state) but the *server-side amplifier* was the zombie writer keeping push loop attention. **Tests.** 3 new cases in `test_vp_mqtt_server.py`. `TestSendPublishDrainTimeoutEviction::test_drain_timeout_raises_broken_pipe_and_closes_writer` patches `asyncio.wait_for` to raise `TimeoutError` immediately and asserts `BrokenPipeError` propagates AND `writer.close()` was called — pins both halves of the contract. `..._still_closes_writer_when_close_fails` covers the best-effort `close()`: even if the writer is already broken and `.close()` raises, `_publish_to_report` must still raise `BrokenPipeError` — silent swallowing here would put us right back to the pre-fix zombie state. `TestHandleClientTCPKeepaliveTuning::test_handle_client_source_names_the_tuning_constants` uses `inspect.getsource` to pin that `_handle_client` references `TCP_KEEPIDLE`, `TCP_KEEPINTVL`, `TCP_KEEPCNT` — a socket-module regression or a stripped-down platform can then be diagnosed from a support bundle. Full VP MQTT + VP manager suites 179/179 green, ruff clean. **Scope.** Backend-only. No new i18n key, no new permission, no DB migration, no frontend change. Users on 0.2.5b1 or earlier with any macOS slicer client: fix takes effect on next Bambuddy restart, no reconfiguration needed. On non-Linux hosts (e.g. Bambuddy running on macOS or FreeBSD for development), the keepalive-schedule tightening is a no-op — the drain-timeout eviction still applies. - **Non-proxy VP camera passthrough is dead for A1 / P1 targets — OrcaSlicer Liveview fails with `[2:-10061]` (#1868, reporter @tom4711-2)** — Symptom on a P1S target in server/non-proxy VP mode: the VP starts 3000, 3002, 8883, 990 and 322, but nothing on 6000, so OrcaSlicer's Liveview button fails. Reporter confirmed a raw `socat` forwarder `:6000 → :6000` immediately restores the stream — the target camera works, the VP just isn't publishing it. **Root cause.** `backend/app/services/virtual_printer/manager.py:1098-1118` hardcoded the camera-passthrough `TCPProxy` to `listen_port=322 / target_port=322` regardless of the target printer's model. That port is correct for RTSPS models (X1/X2/H2/P2S), but A1 / A1 Mini / P1P / P1S use Bambu's proprietary chamber-image protocol on port 6000 — the 322 listener the manager opens for those targets has no upstream, and the slicer's connection to `:322` yields "connection refused". Proxy mode is unaffected because `SlicerProxyManager` (`tcp_proxy.py:1596`) already opens 6000 for file-transfer, and Bambu reuses the same port for chamber-image, so the passthrough coincidentally works there. **Fix.** Read the target printer's model from `printer_manager.get_client(target_id).model` at the same point the target IP is read, then call `get_camera_port(target_model)` — the same source of truth used by `routes/camera.py` and covered by `test_printer_models.py::TestSupportsRtsp` / `TestGetCameraPort` — to pick 322 or 6000. TCPProxy listen_port and target_port both follow the same value. Rename the log tag from `"RTSP"` to `f"Camera-{camera_port}"` so support-bundle grep tells you at a glance which protocol the VP is fronting for. The internal `_rtsp_proxy` attribute name is unchanged to keep the diff tight; the comment above the block spells out that it doubles as chamber-image passthrough on A1/P1. Model comes from the target printer's client instance (`target_client.model`), NOT `self.model` — the VP's spoofed identity has no bearing on how the physical printer serves its camera. **Tests.** New `TestVirtualPrinterCameraPassthrough` in `test_virtual_printer.py` — 4 cases pin the branch: `test_rtsp_model_p2s_opens_port_322` and `test_rtsp_model_x1c_opens_port_322` guard the RTSP path stays on 322 (regression against the fix accidentally routing everything to 6000); `test_chamber_image_model_p1s_opens_port_6000` and `test_chamber_image_model_a1_opens_port_6000` are the direct #1868 guards — P1S / A1 targets must expose 6000. The P1S case additionally asserts NO 322 listener was opened, since a stale 322 on an A1/P1 install would confuse anyone port-scanning the VP. Test harness monkeypatches `TCPProxy` and every peer service (`VirtualPrinterFTPServer`, `SimpleMQTTServer`, `MQTTBridge`, `BindServer`, `VirtualPrinterSSDPServer`, `SSDPProxy`) to lightweight MagicMocks with an already-set `asyncio.Event` on `.ready` — `start_server()` awaits `.ready.wait()` on those four barriers before returning, so an unset event would deadlock the test. 143/143 in the file green (was 139 + 4 new). **Scope.** Backend-only, one-hunk change in `manager.py` plus 4 tests. No i18n key, no permission change, no DB migration, no frontend change. Users on 0.2.5b1 or earlier with A1 / A1 Mini / P1P / P1S targets in non-proxy VP mode: the fix takes effect on the next Bambuddy restart, no reconfiguration needed. The workaround `socat` forwarder can be removed after upgrade. - **Editing a queue item assigned to "Any of model X" left the printer selection area blank** — `PrintModal` initialises `assignmentMode` from `queueItem.target_model`: items created with a specific printer got `'printer'` mode (renders the printer list), items created with model-based assignment got `'model'` mode. But `PrintModal/index.tsx:1102-1106` was passing `onAssignmentModeChange={!isEditing ? setAssignmentMode : undefined}` (same for `onTargetModelChange` / `onTargetLocationChange`), which flipped `modelAssignmentAvailable` to `false` in `PrinterSelector` and hid every model-mode control — the mode toggle at `PrinterSelector.tsx:390`, the model dropdown at `:431`, the location filter at `:459`. Combined with the `assignmentMode === 'printer'` gate on the printer list at `:519`, edit-mode for a model-assigned item rendered an empty container. Users couldn't retarget the item or even see what model it was assigned to; the only way to change it was delete + re-queue. **Fix.** Drop the `!isEditing` gate on all three props — the underlying submit path already handles both directions cleanly (`printer_id: null, target_model, target_location` when saving in model mode at `index.tsx:788-802`; `printer_id, target_model: null, target_location: null` when saving in printer mode at `:844-857`), so un-gating the UI just surfaces the machinery that was already there. Users can now (a) see the current target model + location on a model-assigned item, (b) change the target model or narrow / widen the location filter, and (c) flip the item between "Specific Printer" and "Any of Model" without deleting + re-queueing. Edit is only offered on `pending` items (`QueuePage.tsx:2137`), so there's no race with an in-flight dispatch when the assignment mode flips. **Scope.** Frontend-only, one-hunk change to the props on the existing `PrinterSelector` invocation. No new i18n key (the model / location / mode strings already existed for create mode). No backend change. Existing 61/61 `PrintModal.test.tsx` cases green — the tests that pass `initialSelectedPrinterIds={[1]}` for the create-with-printer flow are unaffected because the `!initialSelectedPrinterIds?.length` gate at `index.tsx:1088` still hides the whole `PrinterSelector` for the post-upload dispatch dialog. - **Finish photo captures the wrong plate on A1 / A1 Mini when SwapMod plate-swap End G-code is injected (#1867, reporter @qoatzelcoat)** — The "Print Complete" snapshot arrived after the user's SwapMod plate-swap moves had already ejected the printed part, so the notification always carried an image of the *swapped* (empty) plate. **Root cause.** The stage-22 ("Filament unloading") edge that normally fires the finish-photo pre-capture never fires on A1 Mini firmware (confirmed in the reporter's log: `FINISH PHOTO MOMENT (FINISH fallback) — stage-22 never fired; capturing at FINISH-state transition`). The fallback path in `bambu_mqtt.py` waits for `gcode_state → FINISH`, and Bambu Studio runs the user-defined End G-code *before* that state transition — so by the time the fallback captures, SwapMod has already moved the plate. **Fix.** Added a new `layer_num → total_layer_num` edge trigger inside `_parse_print_data` that fires the finish-photo moment the instant the last object layer completes, before any end G-code runs. Guarded by the existing `_was_running` / `_finish_photo_captured` one-shot so subsequent stage-22 and FINISH-state ticks become no-ops for the same print. Works on every Bambu variant (A1, A1 Mini, P1P/S, X1C, H2S/H2D) without model detection; on AMS printers the last-layer edge fires slightly before stage-22 would have, which also fixes SwapMod-style setups on those printers rather than only on the A1 family. Test coverage: `TestLastLayerFinishPhotoTrigger` in `backend/tests/unit/services/test_bambu_mqtt.py` (7 cases — fires on last layer, edge-only, skipped on stage-22 replay, skipped on the FINISH fallback, ignores `total=0` bootstrap frames, ignores non-running catch-up messages). ### Added - **Indonesian Rupiah (IDR) currency support (#1869, reporter @qoatzelcoat)** — Added `IDR` with the `Rp` symbol to `frontend/src/utils/currency.ts`; appears in Settings → Cost Tracking and formats spool/print costs as `Rp `. Backend already accepts any 3-letter ISO code (`filament.currency` / `settings.currency` are freeform `String(3)`), so no schema change or migration was needed. ### Added - **Preheat & Heat Soak before queued prints — per-item override + per-filament chamber targets (#1468, reporter @embed-3d)** — New scheduler stage that heats the bed (and the chamber, on printers that support it) and holds at temperature before each queued print starts, intended for engineering filaments (PA, ABS) where adhesion and warp depend on a warm chamber. **Why it doesn't already work in the slicer.** BambuStudio / OrcaSlicer can emit `M191` (wait-for-chamber-temp) in start-G-code, but Bambu firmware silently ignores `M191`, so any "wait for chamber" line in the slicer's start sequence is a no-op. The reporter confirmed this by trying the [MakerWorld chamber-heating G-code](https://makerworld.com/de/models/1200262-g-code-for-x1c-chamber-heating-and-heat-soak) in OrcaSlicer and finding the chamber-heating step wouldn't fire. Implementing this at the orchestration layer — Bambuddy waiting on `state.temperatures` between FTP upload and `start_print` — is the right architectural place; the slicer side is a dead end. **Where it fires.** `print_scheduler._preheat_and_soak()`, called from `_start_print()` immediately before the FTP upload section (`print_scheduler.py:2306`-ish). Best-effort: any failure (printer drops, gcode refused, no bed temp in metadata) logs and returns rather than failing the queue item — the normal upload + start path runs straight after. **Hardware-tier behaviour (three branches; the OP collapsed two of them and we kept them distinct):** (1) Active chamber heater — `H2C / H2D / H2D Pro / H2S / X2D / X1E` (`supports_chamber_heater()` true) — dispatches `M141 Sx` for the configured target then polls `state.temperatures["chamber"]` against it. (2) Chamber sensor only — `X1C / P2S` (`supports_chamber_temp()` true but `supports_chamber_heater()` false) — no `M141`, polls the chamber sensor and considers the chamber phase satisfied when bed radiation has driven the sensor to target. Radiant warm-up to ABS-friendly temps on a cold X1C is 20-30 min — the `max_wait_seconds` cap (default 900 s, range 60-3600) is a hard ceiling so a cold room can't stall the queue indefinitely; falls through to the soak phase if the chamber never converges. (3) No chamber sensor — `P1S / P1P / A1 / A1 Mini` — no chamber wait possible (the `chamber_temper` value these models report is meaningless per `printer_manager.supports_chamber_temp`), so only the bed phase + soak timer apply. **Bed target** is read from the archive's parsed `bed_temperature` metadata (the same `bed_temperature_initial_layer` / `bed_temperature` field `archive.py:438` already extracts from the 3MF); if missing the preheat stage skips and logs rather than guessing a default that might wreck a non-PLA print. **Settings — Settings → Workflow → Queue & Dispatch → Preheat & Heat Soak card.** Master toggle (`preheat_enabled`, default off — disabled installs see no behavioural change), `preheat_chamber_target` (°C, 0-60, default 0 = chamber phase disabled; PA: 50, ABS: 45, PETG-CF: 40), `preheat_max_wait_seconds` (60-3600, default 900), `preheat_soak_seconds` (0-1800, default 300). The numeric fields auto-disable in the UI when the master toggle is off so they read as "config that's not currently doing anything." A static helper line under the inputs spells out the three hardware tiers so users don't have to consult the wiki to know what their printer will do. **Tests.** Eight new cases in `backend/tests/unit/test_scheduler_preheat.py`: disabled-setting skip (no M140 / M141 dispatched), no-bed-temp-in-archive skip, `H2D` dispatches both `M140` and `M141`, `X1C` dispatches only `M140` (the explicit chamber-sensor-but-no-heater regression guard — wiring this to `supports_chamber_temp()` alone would have falsely fired `M141` on the entire X1 family), `P1S` ignores its meaningless `chamber` reading and lets only the soak timer run, `preheat_chamber_target=0` keeps the bed phase but skips chamber even on a heater-capable printer, lost-client mid-flow returns silently, lost-state mid-wait exits the poll loop gracefully and still soaks. `asyncio.sleep` patched to `AsyncMock` so the soak phase doesn't actually wait — assertions are on what was scheduled, not wall-clock. 8/8 green. **Wiki.** `bambuddy-wiki/docs/features/monitoring.md` gains a new "Preheat & Heat Soak" subsection under the queue/scheduling area documenting the three hardware tiers and the four-setting interface, so users with X1C or P1S know upfront what the feature can and cannot do for their printer. **i18n.** 13 new keys × 11 locales (en/de/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW), real translations everywhere — no English fallback. **Scope.** Backend (scheduler + schema + settings route) + frontend (settings card + AppSettings type) + tests + wiki. No DB migration (uses the existing key/value `Settings` table). No new permission (Settings → Workflow already gates on the same admin scope). The OP's "select option per print" UX is not part of this drop — preheat is a global default applied to every queued item that has a parseable bed temperature; per-queue-item override would need a `PrintQueueItem` schema migration plus print-modal and queue-modify UI work that would have widened the change beyond the scope agreed with the user. **Rework on user review.** First cut shipped a global-only design: one single `preheat_chamber_target` int in Settings → Workflow, no per-print override. User flagged two gaps on review: (1) you can't enable preheat for a single queue item, (2) different filaments need different chamber temps but the setting was a single value. Both are fair: PA needs 50, PETG-CF wants 40, PLA wants 0, but the global single-int forced one number across all of them. Reworked the data shape: replaced the single `preheat_chamber_target` int with `preheat_filament_targets` (JSON map of normalised filament type → °C, user-editable in the same card via the new `PreheatFilamentTargetsEditor` component) and added two columns to `PrintQueueItem` — `preheat_override` (`inherit` / `on` / `off`, default `inherit`) and `preheat_chamber_target_override` (nullable int, beats the filament-map derivation). The scheduler's resolution order: `item.preheat_override == 'off'` skips entirely; `'inherit'` falls back to the global `preheat_enabled` master toggle; `'on'` forces the stage even when the global is off. Chamber target: `item.preheat_chamber_target_override` (explicit °C) > max of `preheat_filament_targets[normalize(t.tray_type)]` across loaded AMS slots > 0. Mixed PA+PLA load picks PA's 50 (max-across-slots, NOT lowest-common-denominator — PA's chamber requirement is the binding constraint, PLA doesn't suffer from being warm). PLA-only prints derive 0 and skip the chamber phase automatically without the user touching anything. The per-print UI lives in `PrintModal`'s "Print Options" panel — tri-state segmented control (Inherit / On / Off) plus an optional chamber-target override input (shown only when override ≠ Off, blank = use filament map). Same control in edit-queue-item mode so you can flip preheat on an already-queued print. Tests grew from 8 cases to 15 across three categories — override resolution (3), chamber-target derivation (5), hardware-tier branching (5), plus 2 helper-fn tests — all green. DB migration added: `preheat_override VARCHAR(10) DEFAULT 'inherit'` and `preheat_chamber_target_override INTEGER NULL` on `print_queue`, idempotent via `_safe_execute`. Existing rows behave exactly as before (inherit + null = use global). i18n grew from 13 keys to 19 × 11 locales — real translations for the new override radio + per-filament editor strings, no English fallback. Frontend `PrintQueueItem` TS interface and `PrintQueueItemCreate` / `PrintQueueItemUpdate` shapes updated to carry the new fields end-to-end. **PrintOptions.tsx now uses `options[key as 'bed_levelling']` for the boolean rows since `options[key]` would type-error against the new non-boolean preheat keys** — minor TS-only adjustment, no behavioural change. **Airduct flap follows the resolved chamber target — bidirectional, idempotent.** Reported by user mid-test on H2D: preheat ran M141 for ABS but the cooling/heating airduct flap stayed in cooling, the open exhaust vent actively fought the heater and the chamber crawled toward target. The H-series (H2C/H2D/H2D Pro/H2S), X2D, and P2S all have a motorised flap with two modes: cooling (modeId=0, open exhaust, vents heat — right for PLA/PETG/TPU) and heating (modeId=1, closed exhaust, recirculates warm air — right for ABS/ASA/PC/PA). Bambu's firmware does **not** auto-switch the flap based on M141; whatever mode the user last left it in persists. So a PLA→ABS workflow inherits PLA's cooling-mode flap and the heater never wins; conversely an ABS→PLA workflow inherits ABS's heating-mode recirculation and runs PLA's chamber hot. **Fix.** New `supports_airduct(model)` helper in `printer_manager.py` mirrors the frontend whitelist (`P2S`, `X2D`, `H2C`, `H2D`, `H2D Pro`, `H2S` plus their internal codes). In `_preheat_and_soak`, after the bed dispatch and BEFORE the chamber M141: read the printer's current `state.airduct_mode`, derive the desired mode from the resolved chamber target (`chamber_target > 0` → heating, `chamber_target == 0` → cooling), and only fire `set_airduct_mode` when current ≠ desired. **The bidirectional switch is the load-bearing part** per Martin: when the resolved chamber target is 0 (PLA-only print, or per-item override disables chamber), the flap MUST switch to cooling even on a heater-capable printer that was previously running ABS, otherwise the closed-flap recirculation cooks PLA. The idempotency check (read `state.airduct_mode`, compare against desired before sending) keeps the flap motor from cycling needlessly when it's already where we want it. **Gating** is on `supports_airduct(model)` only — distinct from `supports_chamber_heater(model)`: X1E has a chamber heater but no flap (skipped), P2S has a flap but no active heater (still flipped, because even a passive-sensor printer benefits from the right airflow for its filament); the intersection that needs both is H2C/H2D/H2D Pro/H2S/X2D and they all work. **No post-print restoration** per Martin's preference — once a preheat sets the flap, it stays there for the print's duration (the print itself wants the same mode the preheat picked) and for any subsequent prints until the next preheat decision flips it. Best-effort: any `set_airduct_mode` failure logs and continues; M141 still fires regardless so a stuck flap doesn't kill the print. **Tests.** Four new cases in `test_scheduler_preheat.py`: `test_h2d_chamber_heat_switches_airduct_to_heating` (the OP scenario — cooling→heating before M141 for ABS), `test_h2d_chamber_zero_switches_airduct_to_cooling` (heating→cooling for a PLA print on a previously-warm flap), `test_h2d_airduct_already_correct_idempotent` (no command sent when current mode matches desired), `test_x1c_no_airduct_flap_never_fires_set_airduct` (gate regression guard — X1C has chamber sensor + chamber heater logic adjacent but no flap, must not leak the command). 21/21 in the file now. ### Added - **API keys can log maintenance — new "Manage Maintenance" scope for HA-style automations (#1832 follow-up, reporter @MorganMLGman)** — Follow-up on the closed #1832 thread: reporter noted that maintenance had no checkbox in the API-key permissions UI and wasn't clearly denied in the wiki, unlike the other admin-only surfaces. Root cause was that `MAINTENANCE_CREATE / _UPDATE / _DELETE` sat on `_APIKEY_DENIED_PERMISSIONS` in `backend/app/core/auth.py`, so every API-key call to `POST /maintenance/items/{id}/perform` (mark maintenance as done — the useful endpoint for a "cleaned nozzle every N hours" Home Assistant automation) returned `403 "API keys cannot be used for administrative operations."` **Fix.** New `can_manage_maintenance` scope flag on `api_keys`, following the same shape as `can_manage_library` and `can_manage_inventory`. Wires the three maintenance write permissions into `_APIKEY_SCOPE_BY_PERMISSION` under the new flag; drops them from the denylist. Covers the per-printer maintenance CRUD (assign/remove items, edit intervals, log completion), the type-catalog CRUD (system types + custom types), and — via `MAINTENANCE_UPDATE` on the perform endpoint — the load-bearing "reset counter" call. `MAINTENANCE_READ` stays under `can_read_status`. **Backfill choice.** Distinct from the `can_manage_library` / `can_manage_inventory` migrations, which mirrored `can_queue` to preserve existing capability from the prior denylist-model. Maintenance writes were EXPLICITLY denied for every API key pre-migration, so no existing integration relies on them — backfilling to `can_queue` would silently widen scope on upgrade for every queue-capable key without unlocking any real use case. Existing rows backfill to **FALSE**; new keys created via the Settings UI default to **TRUE** (matches the safe-on-by-default pattern for the two prior scopes). Users opt in per key from Settings → API Keys → Edit. **CLI.** Bundled SpoolBuddy kiosk key gets `can_manage_maintenance=False` — kiosks don't need it, keep them minimally scoped. **Tests.** RBAC matrix in `backend/tests/integration/test_auth_apikey_rbac.py` extended: `valid_flags` set + `_each_scope_flag_has_at_least_one_permission` parametrize decorator + `_FakeApiKey.__init__` all pick up the new flag; three new `_SCOPE_CASES` for `MAINTENANCE_CREATE / _UPDATE / _DELETE`; the cross-scope leakage guard (`other_flags` set at line 355) widened from four flags to six so a `can_manage_library` toggle can't accidentally allow a `MAINTENANCE_UPDATE` call (and vice versa). 78/78 in `test_auth_apikey_rbac.py` + adjacent auth suites green. **Frontend.** New checkbox in the Settings → API Keys create form (between Manage Inventory and Allow Cloud Access), new teal badge in the key list, TS types extended (`APIKey`, `APIKeyCreate`, `APIKeyUpdate`). **i18n.** Three new keys (`settings.manageMaintenance`, `settings.manageMaintenanceDescription`, `settings.maintenanceBadge`) × 11 locales (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW), real translations everywhere per convention. **Wiki.** `bambuddy-wiki/docs/features/api-keys.md` — new row in the permissions table, new bullet in the Principle-of-Least-Privilege list ("Home Assistant maintenance-log automation: Read Status + Manage Maintenance"), and a paragraph in Upgrade Notes explaining the FALSE backfill so existing users don't see a silent scope widening. **Migration.** Idempotent `_safe_execute` `ALTER TABLE api_keys ADD COLUMN can_manage_maintenance BOOLEAN DEFAULT TRUE` + one-shot `UPDATE api_keys SET can_manage_maintenance = FALSE` guarded by column-existed-before check, mirroring the two prior scope migrations. Works on both SQLite and Postgres — no dialect-specific column type needed since it's just a BOOLEAN. ### Fixed - **AMS same-material runout/backup switch: Spoolman now splits usage across origin + backup spool (#1793, reporter @ojimpo)** — Reporter's H2S ran an AMS backup switch mid-print (single-slot 72.56g job, tray 0 depleted at layer 37 → auto-switched to tray 1 → finished). Bambuddy's Spoolman writer charged the ENTIRE 72.56g to the origin spool via the `(via tag)` path and separately credited a small `~30g via remain-delta` to the backup — net double-count plus origin spool driven past its `initial_weight`. Reporter's forensic trace on 0.2.5b2 confirmed: `Tray change during print: tray=1 at layer=37` fires at `bambu_mqtt.py:1863` and gets appended to `state.tray_change_log`, but `spoolman_tracking.report_usage` never consulted it — every mid-print switch fell through to single-tray attribution. #1771's fix (`a53dc20ca`) corrected the split *math* inside the tray-switch branch of the internal `usage_tracker` writer; the branch itself was **never ported to `spoolman_tracking`**, so users on Spoolman have never had a working split even after #1771 shipped. **Fix — extract the shared split math and use it from both inventory writers so the two paths cannot drift.** New pure-logic helper `backend/app/utils/tray_split.py::compute_tray_split_grams` — takes `tray_changes`, `total_weight`, `slot_id`, optional `layer_usage`, density/diameter/total_layers/last_layer_num, returns per-segment `[(seg_idx, global_tray_id, grams)]`. Preference order matches usage_tracker exactly: (1) G-code cumulative extrusion between segment boundaries, (2) linear layer-ratio with `denom = total_layers or last_layer_num` (P1S firmware resets `total_layer_num` to 0 at completion — `last_layer_num` is the durable denominator, #1771's cascade), (3) equal-split when no denominator survives. Last segment always absorbs rounding drift so the sum equals `total_weight` exactly, no phantom grams created or lost. `usage_tracker.py:1085-1150`'s inline split loop now calls the helper; the caller-side "convert global_tray_id → (ams_id, tray_id) → resolve spool → charge grams" side effect stays where it was. `spoolman_tracking.report_usage` gains a new `_report_spool_usage_split_by_tray_changes` peer that mirrors `_report_spool_usage_for_slots`'s Spoolman side effects (tag → `find_spool_by_tag` → slot-assignment fallback → `use_spool`) but iterates SEGMENTS instead of raw slots. Split path activates when `len(state.tray_change_log) > 1` at completion AND the print used exactly one nonzero slot (`len(nonzero_slots) == 1`) — same gate as `usage_tracker.py:1002`. Multi-colour prints naturally cycle trays for every colour change, so splitting each slot's grams across every `tray_change_log` entry would attribute slot 1's usage to segments where slot 2's tray was loaded and vice versa. Multi-slot prints fall through to the existing single-tray path with its stable `slot_to_tray` mapping. Single-entry log (start-of-print seed only, no mid-print switch) also falls through, so nothing changes for prints that didn't traverse an AMS switch. **Double-count fix.** After the split path attributes segment N to `global_tray_id`, that ID enters `handled_global_tray_ids` — so Path 2 (remain-delta fallback for slots 3MF didn't cover) skips it. Pre-fix, the OP's backup spool got a redundant `remain-delta` credit on top of the origin overcharge; post-fix, remain-delta only fires for trays the split path genuinely didn't touch. **Sample A math end-to-end.** Reporter's 72.56g single-slot print with `tray_change_log=[(0, 0), (1, 37)]` and no gcode layer usage: seg 0 (tray 0, layers 0-37) gets `72.56 × 37/100 = 26.85g` → spool 8; seg 1 (tray 1, layers 37-end) gets `72.56 - 26.85 = 45.71g` → spool 7. Origin no longer exceeds `initial_weight`; backup carries the segment actually printed from it. Sample B (paused-then-switched print at layer 371 on a 263.47g single-slot job) uses the identical code path — pause/resume doesn't flip the `self._was_running and not self._completion_triggered` gate at `bambu_mqtt.py:1860`, so the tray-change log survives the pause window. One fix covers both. **Tests.** 4 integration cases in `backend/tests/unit/services/test_spoolman_tray_split.py` pin the Sample A shape, the Path 2 double-count guard, the multi-slot no-split gate, and the single-tray-change-entry fallthrough. Plus 8 pure-math cases in `backend/tests/unit/utils/test_tray_split.py` pinning the algorithm — empty log → empty result, single segment → charge everything to that tray, two-segment linear split, gcode-preferred-over-linear branch (with a regression guard on `slot_id → filament_id = slot_id - 1` because dropping the -1 sends the split to a filament_id that isn't in the gcode and dumps everything onto the last segment — the exact #1771 shape), three-segment last-absorbs-rounding, `total_layers=0 & last_layer_num>0` cascade, equal-split when no denominator, and cross-validation that captured-layer scenarios match the total_layers path. 4 integration cases in `backend/tests/unit/services/test_spoolman_tray_split.py` pinning the Sample A shape end-to-end — the seamless-switch case (two `use_spool` calls, sum=72.56, origin gets 26-28g, backup gets 44-47g), the double-count guard (Path 2 gets valid slot-assignment IDs for both trays so a broken guard would surface as 4 `use_spool` calls instead of 2), the multi-slot no-split gate (multi-colour print with 5 tray-changes must fall through to single-tray attribution, not fan slot 1's grams across slot 2's segments), and the single-tray-change-entry fallthrough (must NOT split a normal single-tray print). Extended existing `test_usage_tracker` regressions still pass 112/112 — refactoring the inline split path to use the shared helper is behaviour-preserving. `pytest -n 30 backend/tests/ -k "spoolman or usage_tracker or tray_split or partial"`: 798/798 green. **Compat.** `report_usage` uses `getattr(tracking, "layer_usage", None) or {}` and `getattr(tracking, "filament_properties", None) or {}` — attribute-safe against SimpleNamespace stubs in tests AND against pre-migration ORM rows loaded without those columns. **Scope.** Backend-only. Two-file port (usage_tracker refactor + spoolman_tracking split path) + one new helper module + 11 new tests. No DB migration (fields already existed on `ActivePrintSpoolman` from earlier work). No new permission. No new i18n key. No frontend change — the `spool_usage_logged` WebSocket already carries per-segment results, so the UI updates without a client-side change. - **P1S / P1P printer card no longer shows a bogus "Door Closed" badge (#1866, reporter @MartinNYHC)** — The enclosure-door badge in `frontend/src/pages/PrintersPage.tsx` gated visibility on a model whitelist that included `P1S` and `P1P`, but neither has a door sensor: P1S has an enclosure door with no hall sensor behind it, P1P has no enclosure at all. Backend `bambu_mqtt.py:2876-2907` unconditionally parses bit 23 of the `stat` field for every non-X1 model, and the bit stays 0 on P1S/P1P firmware — so the card rendered a permanent green "Door Closed" chip that couldn't reflect real state. **Fix.** Drop `P1S` and `P1P` from the frontend whitelist. Whitelist now reads `['X1C', 'X1', 'X1E', 'X2D', 'P2S', 'H2D', 'H2D Pro', 'H2C', 'H2S']` — the models that ship with a hall sensor for the door (and, on X1 family, top glass). Corrected the stale `X1/P1S/P2S/H2*` comment in `frontend/src/api/client.ts:473` (TypeScript `PrinterStatus` interface) and `backend/app/services/bambu_mqtt.py:295` (`PrinterState` dataclass) to match reality — those comments were what led the whitelist astray in the first place. Backend parsing left as-is: the bit read is cheap and if Bambu ever ships firmware that wires the P-series enclosure into a door sensor, the state gets picked up for free without a Bambuddy change. **Scope.** Two-line frontend whitelist change plus two comment corrections. No test change (no unit tests existed for the frontend gate). No i18n key change (`printers.door.open`/`printers.door.closed` still used for the models that keep the badge). No DB migration. No new permission. No backend behavioural change. - **Bambu Cloud custom filament preset lookup restored — SpoolBuddy "Assign to AMS" now surfaces the real custom profile in BambuStudio (#1815, reporter @Bgabor997)** — Symptom: SpoolBuddy scans a tag for a spool whose slicer_filament is a Bambu Cloud user preset (PFUS-prefix, "PFUS12f68b29a18aa4" etc.), Bambuddy applies the assignment, and BambuStudio's AMS panel shows "Generic PETG" / "Generic PLA" instead of the user's custom profile. Manual AMS-card configure with the same profile worked. **Root cause.** `BambuCloudService.get_setting_detail` at `backend/app/services/bambu_cloud.py:393` hits `GET /v1/iot-service/api/slicer/setting/{setting_id}` without the `?version=XX.YY.ZZ.WW` query parameter — Bambu's API answers `HTTP 400 "field 'version' is not set"` for every call. The plural GET five methods above (`get_slicer_settings`, line 362) *does* send `params={"version": _SLICER_API_VERSION}` and the source comment at line 69-77 documents precisely this contract for the endpoint subtree; the singular GET and the sibling DELETE (`delete_setting`, line 545) were left uncovered when the `_SLICER_API_VERSION` placeholder landed in #1013's compliance rework 2026-05-12. `slicer_filament_resolver.resolve_slicer_filament` swallowed the 400 as a warning and fell through to `normalize_slicer_filament`; the caller in `inventory.py:146-165` then generic-material-fell-back tray_info_idx to `GFL99`/`GFG99`, and BambuStudio's AMS panel reads the printer's `tray_info_idx` echo → Generic. **Why nobody caught this in 50 days.** Two rescue paths mask the bug in 99% of real assigns: (1) if the target slot already carries a P-prefix filament_id from any prior configure, `current_tray_info_idx` reuse at `inventory.py:147-156` reuses it; (2) if the spool has a stored `spool_k_profile` matching a live `state.kprofiles` entry on the printer, `printer_kp.filament_id` realigns tray_info_idx at line 216-230 (`Spool assign: realigning tray_info_idx 'GFL99' → 'P…' (source=printer)`). Reporter's spool 54 → tray 2 assign at 2026-06-30 09:21:18 was the rare case with neither rescue — fresh spool, no prior K-profile calibration, slot didn't hold a valid P-prefix — and fell all the way to generic. Every other PFUS assign in his same log shipped a valid P-prefix. Owner reproduced the WARNING line in his own log on the H2D after a Reset-Slot + rescan cycle; his display was rescued by the K-profile realign path, not by cloud. **Fix.** `get_setting_detail` and `delete_setting` now both send `params={"version": _SLICER_API_VERSION}` — same neutral `"1.0.0.0"` placeholder the plural GET has always used (Bambu accepts any XX.YY.ZZ.WW value, doesn't validate against a release manifest, so no impersonation). Once cloud returns 200, the resolver reads `detail["filament_id"]` at line 108-113 and the P-prefix lands on tray_info_idx directly — no reliance on slot reuse or K-profile realign. The endpoint-subtree comment at line 69-77 updated to name the singular GET, DELETE, and POST variants explicitly so the next sibling method added to this class doesn't silently regress. `get_setting_detail` also includes the truncated response body in the raised `BambuCloudError` message, so a future contract change is self-diagnostic from support-bundle logs (this bug cost 50 days precisely because the error was an opaque `"400"`). **Adjacent surfaces that were also silently broken and are now fixed.** `preset_resolver.resolve_preset`'s cloud branch, `cloud.py`'s three UI-facing routes (`get_setting_detail` at `:534`, `import_setting` chain at `:784`, forecast at `:1120`), `cloud.py`'s delete-cloud-preset route (`:1016`), and internally `BambuCloudService.update_setting` at `:483` (which calls `get_setting_detail` then `delete_setting` then POSTs a replacement — every UI-driven edit of a cloud preset was 400ing at step 1). **Tests.** Three new cases in `TestSlicerSettingVersionParam` (`backend/tests/unit/services/test_bambu_cloud.py`) pinning the contract as a unit-testable invariant so the next sibling method can't silently omit the param: `test_get_setting_detail_sends_version_param` asserts `params.get("version")` is truthy on the GET call; `test_get_setting_detail_error_includes_response_body` pins the reporter's exact 400 body shape (`"field 'version' is not set"`) making it into the raised exception message; `test_delete_setting_sends_version_param` mirrors the invariant for DELETE. 26/26 in `test_bambu_cloud.py` (was 23 + 3 new). `ruff check backend/app/services/bambu_cloud.py backend/tests/unit/services/test_bambu_cloud.py` clean. **Scope.** Backend-only. Two API-call edits, one comment edit, three tests. No DB migration. No new permission. No frontend change. No new i18n key. Users on 0.2.5b1 and earlier who hit this: the fix takes effect on the next Bambuddy restart with no data migration required — reassigning the affected spool via SpoolBuddy after upgrade lands the correct tray_info_idx and the AMS panel in BambuStudio updates to the actual custom profile name. - **Cancel during queue dispatch actually cancels — no more "pressed cancel and the print started" (#1853, reporter @guy-blotnick)** — Symptom: user queued a batch of 10 print jobs of the same item across two P2S printers (Windows installation, v0.2.4.8), clicked Cancel on a pending row, and the print started anyway. Repeated consecutively. Support-bundle log scan reported 15× `sqlite3.OperationalError: database is locked` from the printer sensor history recorder in the same 8-minute window — a tell-tale that something was holding the SQLite WAL writer lock for the full 15 s `busy_timeout`. **Root cause (the race).** `_start_print()` in `backend/app/services/print_scheduler.py` carried a check-then-act window of several seconds between "scheduler's `check_queue` snapshotted this row as pending" and "scheduler sent the MQTT start_print command". The scheduler reads `item` via its session, then does FTP delete + FTP upload of the 3MF (5-30 s on a typical archive) before the unconditional `item.status = "printing"; await db.commit()` at line 2792. If the user pressed Cancel in that window, `/cancel` (a separate session) saw `status == 'pending'`, flipped to `cancelled`, and returned 200 — but the scheduler's stale in-memory write overwrote the cancellation in the very next commit. Then `printer_manager.start_print` shipped to the printer and the print commenced. The `cancel_queue_item` route at `print_queue.py:1245` correctly guards against late cancels (`status not in ("pending",)` → 400) but is useless if the scheduler races back to `pending → printing` AFTER the cancel commit. **Root cause (the lock contention amplifier).** `_start_print()` did `await db.flush()` at line 2555 immediately after writing `item.archive_id = archive.id` and `await db.delete(library_file)` (the library-file-to-archive promotion path) — `flush()` opens the SQLite write transaction but doesn't commit, holding the WAL writer lock through the FTP upload below. Every other writer in the process (sensor history task every 60 s, runtime tracking every 30 s, MQTT state UPDATEs from the live H2D/P2S, the user's own concurrent /cancel commits) blocks behind that lock for the full upload duration. With 15 s `busy_timeout` and FTP uploads regularly exceeding 15 s on larger 3MFs, the sensor history task hit its first lock error → logged + slept 60 s → next tick still blocked → repeat. The lock contention also widened the cancel-race window (the user's cancel commit was queued behind the scheduler's held write), making the race that much easier to lose. **Fix is three guards layered in defence-in-depth.** **(1) Atomic CAS at the pending→printing transition.** Replaced `item.status = "printing"; await db.commit()` with `UPDATE print_queue SET status='printing', started_at=NOW() WHERE id=:id AND status='pending'`. If `rowcount == 0` the user already won the race; log the abort, best-effort `delete_file_async` the file we just FTP'd up to the printer's SD card (no leftover that would surface in BambuStudio's file picker), send a `queue_item_failed{reason: "cancelled_mid_dispatch"}` WebSocket event so the user sees their cancel actually took effect, and return WITHOUT calling `printer_manager.start_print`. The in-memory `item.status` / `item.started_at` are synced from the CAS values so the rest of `_start_print` reads consistent state for notifications. **(2) Early refresh + bail before FTP I/O.** Right after the printer-connectivity check (~line 2487) the scheduler now `await db.refresh(item)` and returns early if `item.status != "pending"`. Saves the wasted 5-30 s FTP upload when the user cancelled BEFORE the scheduler tick reached this row (the snapshot is taken at the top of `check_queue` but iterated through serially; with 10 items in the batch the last item can be picked up minutes after the snapshot, by which time it may already be `cancelled`). Not load-bearing for correctness — guard (1) catches the same case at the CAS point — but cuts wasted FTP bandwidth, printer SD writes, and downstream cleanup work. **(3) `flush()` → `commit()` before FTP.** Replaced the `await db.flush()` at line 2555 with `await db.commit()` so the library-file-to-archive promotion's writes (`item.archive_id` set, `library_file` deleted) commit cleanly before the FTP block. SQLite WAL writer lock releases immediately; concurrent writers — the sensor history task, the user's /cancel commit, the MQTT UPDATE path — stop queueing behind the scheduler's session. The flush-not-commit pattern existed because the original code wanted to roll back the archive promotion if a later step failed, but `archive_service.archive_print()` (called four lines above) had already committed the `PrintArchive` row in its own session, so the rollback was only ever rolling back the `item.archive_id` pointer back to NULL — which doesn't actually undo the archive creation. The new behaviour matches reality: archive is created (committed), pointer is set (committed), FTP runs without writer-lock contention. Subsequent failure paths still mark the item as `failed` correctly; they don't try to un-create the archive. **Tests.** Three new cases in `backend/tests/unit/test_scheduler_cancel_race.py`: `test_cancel_during_ftp_upload_aborts_before_mqtt` simulates the headline scenario (cancel commits in a separate session inside `upload_file_async`'s mock side-effect, then the CAS sees rowcount==0 → `start_print` mock asserted not-called → row stays `cancelled` → `started_at` stays `None` → two `delete_file_async` calls, one pre-upload sweep and one post-CAS cleanup); `test_cancel_before_ftp_upload_skips_dispatch` mirrors the early-bail path (row pre-cancelled before `_start_print` runs → upload never awaited → `start_print` never called); `test_happy_path_still_dispatches` is the regression guard so the CAS doesn't accidentally block normal dispatch on a row that was always pending. 3/3 green + 140/140 in the wider scheduler suite (`test_scheduler_cleanup_library.py`, `test_scheduler_preheat.py`, `test_scheduler_dispatch_hold.py`, `test_scheduler_watchdog.py`, `test_scheduler_ams_mapping.py`) regression-clean. **Scope.** Backend-only. No DB migration. No schema change. No new permission. No new i18n key. No frontend change — the existing `queue_item_failed` WebSocket toast already renders for the new `cancelled_mid_dispatch` reason via its generic display path. The `database is locked` errors are addressed structurally by guard (3); a more invasive session-lifetime restructure inside `_start_print` was considered and deferred — flush→commit catches the dominant offender (library-file-promoted dispatches, which the OP's 10-item batch hits per copy) without widening the change beyond what #1853 needs. - **`Inject auto-print G-code` checkbox can be ticked in PrintModal create mode (#1852, reporter @Lamcois)** — Symptom: in v0.2.4.8 the user opens the print dialog for a single archive, sees the *Inject auto-print G-code* toggle next to the gcode-snippet section, clicks it, and the checkbox visually flips back to unchecked instantly. Submitting and then editing the queued item lets the same toggle be ticked normally — so the bug was only in the create-mode flow. **Root cause.** `PrintModal/index.tsx:947-955` carried a `useEffect` that reset `scheduleOptions.gcodeInjection` to `false` whenever `mode === 'create'` AND `(effectiveQuantity <= 1 || !settings?.gcode_snippets)`. The reset's stale code-comment claimed "the checkbox only renders for create + snippets configured + quantity > 1" — but the actual render gate in `ScheduleOptions.tsx:277` is just `{hasGcodeSnippets && (...)}` with no quantity check. So with snippets configured + quantity = 1 (the OP scenario): user clicks the checkbox → React updates state to `true` → the parent's useEffect immediately sees the gate's `effectiveQuantity <= 1` condition and resets it to `false` → the checkbox appears un-clickable. Edit-queue-item mode worked because `mode !== 'create'` short-circuited the reset before it could fire. **Fix.** Drop the `effectiveQuantity <= 1` clause from the reset. The legitimate cleanup that survives — the `!settings?.gcode_snippets` half — handles the actual edge case the effect was guarding against: an admin removing every snippet while the modal is open, in which case the checkbox's render gate hides the control but the boolean would otherwise still be `true` on submit. The scheduler's `_start_print` (`print_scheduler.py:2306`) reads `item.gcode_injection` per queue item regardless of batch size, so there's no underlying reason to block injection on single prints — that gate was inserted in error and never matched the render condition. **Tests.** New regression case in `frontend/src/__tests__/components/PrintModal.test.tsx`: `quantity 1 + snippets configured: checkbox toggles cleanly (#1852)` opens the modal in create mode at the default quantity = 1, asserts the checkbox starts unchecked, clicks it, `waitFor` confirms the displayed `checked` state stays `true` after re-render (the pre-fix reset would have flipped the displayed `checked` back to `false`), and confirms the `gcode_injection: true` flag actually reaches the queue API on submit. 61/61 in `PrintModal.test.tsx` (was 60 + 1 new). Existing batch-mode case (`injection ON queues all copies and dispatches none immediately`) still passes — my fix doesn't affect the quantity > 1 multi-copy fan-out path. **Scope.** Frontend-only, one-line behavioural change inside an existing effect. No backend change, no i18n key, no permission. - **`Ignore and Resume` now actually ignores the fault — wrong-plate HMS no longer re-pauses 1-2 s after click** — Symptom (continuation of #1869): clicking `Ignore and Resume` on a wrong-plate `0500_8051` HMS cleared the modal, the printer left PAUSE, then ~1-2 s later re-detected the wrong plate and re-paused with the identical HMS code — the modal popped back open and the user could not actually print on the "wrong" plate (the whole point of the Ignore button). **Root cause:** Bambuddy's `execute_hms_action` (`backend/app/services/bambu_mqtt.py:5496-5523`) redirected `IGNORE_RESUME` on `state == "PAUSE"` to a plain `resume` command, with a code comment claiming BambuStudio's "err-bearing shape" was "silently rejected by Bambu firmware" (verified during #1830). That diagnosis was wrong on two counts. (1) **`IGNORE_RESUME` doesn't map to `resume` at all in BambuStudio.** Source-of-truth from BambuStudio `src/slic3r/GUI/DeviceErrorDialog.cpp:600-602` and `src/slic3r/GUI/DeviceManager.cpp:1450-1462` (commit `4019d2e`): the button dispatches `command_hms_ignore`, whose wire shape is `{"print": {"command": "ignore", "err": "", "param": "reserve", "job_id": "", "sequence_id": "..."}}`. The firmware treats `command: "ignore"` as "suppress this check on the next attempt AND auto-resume the paused print" — semantically distinct from `command: "resume"` which means "I fixed the problem, re-check normally" and is exactly why the wrong-plate check re-fired. (2) **`err` is a DECIMAL string of the int, not the hex shortcode.** BambuStudio passes `std::to_string(m_error_code)` where `m_error_code` is the 32-bit `int` value of the print_error code — so for `0x05008051` the wire string is `"83918929"`, not `"05008051"`. The #1830 H2D test that "verified" the err-bearing shape was silently rejected almost certainly sent the hex string, which the firmware couldn't match against the active int fault → silent rejection looked like the entire shape was broken, when the actual problem was the format of one field. **Fix.** Replace the `hms_ignore(persistent)` helper with two distinct helpers matching BambuStudio's source: `hms_ignore_command()` publishes `command_hms_ignore`'s shape (`command: "ignore"`, decimal `err`, `param: "reserve"`, `job_id`); `hms_idle_ignore(persistent)` publishes `command_hms_idle_ignore`'s shape (`command: "idle_ignore"`, decimal `err`, `type: 0|1`). Wire `IGNORE_RESUME`, `IGNORE_NO_REMINDER_NEXT_TIME`, and `DONT_REMIND_NEXT_TIME` (alias) to `hms_ignore_command()` — BambuStudio routes all three to the same `command_hms_ignore` (`DeviceErrorDialog.cpp:596-602`), the "don't remind" half is the firmware's job. Wire `NO_REMINDER_NEXT_TIME` to `hms_idle_ignore(persistent=False)` — BambuStudio dispatches it via `command_hms_idle_ignore(..., 0)` (`DeviceErrorDialog.cpp:588-590`), distinct from the resume-bearing `ignore` command. The decimal-int conversion lives at the helper layer (`str(int(print_error, 16))` with a defensive fallback to the raw input on parse failure so the route can surface 502 rather than raising mid-dispatch). The `job_id=None` case sends an empty string, matching BambuStudio's `std::string` empty default. **Resume / stop unchanged** — the user has independently confirmed plain `resume` and plain `stop` work for `PROBLEM_SOLVED_RESUME` and `STOP_PRINTING` on their printer; BambuStudio's `command_hms_resume` / `command_hms_stop` do carry the same `err`+`param: "reserve"`+`job_id` fields, but changing a working shape without a field test risks regressing a path the user has confirmed, so the plain shape stays. The previous stale comments about "verified silently rejected" are removed and replaced with citations to BambuStudio's exact source lines. **Tests.** Six cases in `TestExecuteHmsActionDispatch` (`backend/tests/unit/services/test_hms_actions.py`) rewritten to assert the BambuStudio shape: `test_ignore_resume_sends_bambustudio_ignore_command_paused` pins the full payload (the exact #1869 trace), `test_ignore_resume_state_independent` confirms no PAUSE/RUNNING branch (BambuStudio's dispatch is unconditional), `test_ignore_no_reminder_uses_ignore_command_not_idle_ignore` pins the `DONT_REMIND_NEXT_TIME` → `command: "ignore"` route, `test_no_reminder_next_time_uses_idle_ignore_type_zero` pins the still-correct `NO_REMINDER_NEXT_TIME` → `idle_ignore` route (decimal `err` now), `test_ignore_accepts_16_char_full_code_as_decimal` covers the 64-bit hms[]-array fault shape, `test_ignore_with_no_job_id_sends_empty_string` pins the empty-string sentinel. 35/35 in `test_hms_actions.py`, 12/12 in HMS-touching `test_printers_api.py` integration cases, `ruff check` clean. **Scope.** Backend-only. Same modal, same API contract, same dispatch route — just a different MQTT command shape on the wire for the ignore branch. No DB migration, no permission, no i18n key. - **HMS error-modal action buttons look like buttons and stop falsely 502-ing on wrong-plate `Ignore and Resume`** — Two compounding issues in the HMS error modal surfaced when a user forced an HMS error for a wrong build plate and tried to dispatch the per-fault actions. (1) **Buttons read as inert badges.** The action `