# Changelog All notable changes to Bambuddy will be documented in this file. ## [0.2.5b1] - Unreleased ### Added - **System theme detection — sidebar toggle and Settings selector follow OS dark/light preference (#1418, contributed by @TempleClause via PR #1501)** — `ThemeMode` gains a third value `'system'` alongside the existing `'dark'` / `'light'`. The provider listens to `window.matchMedia('(prefers-color-scheme: dark)')`, tracks the OS preference in real time, and exposes a new `resolvedMode: 'light' | 'dark'` to consumers — the actual rendered theme after resolving system → OS preference. Layout's sidebar toggle now cycles `dark → light → system → dark` with the icon hinting at the next stop (`Sun`→`Monitor`→`Moon`); the existing logo selection and the dark/light "active" panel highlight in Settings switched from `mode` to `resolvedMode` so they always reflect what's actually painted, regardless of whether the user chose explicitly or inherited from the OS. Settings → Appearance gained a 3-button Dark / Light / System selector (border-green-keys-off-`mode` so System actually highlights System even when it resolves to dark), with a "Settings saved" toast on click matching the adjacent Background/Accent/Style selects. Existing users' persisted `theme-mode` is untouched — anyone on `dark` or `light` stays there and simply gains an extra stop in the cycle; new installs default to `dark`. **Review-caught fixes shipped in the same PR**: (a) the project's `__tests__/setup.ts` mocked `window.matchMedia` with `vi.fn().mockImplementation(...)`, which `vi.restoreAllMocks()` in three test files reset to "return undefined" — pre-PR nothing called `matchMedia` at render time so the wipe went unnoticed, this PR was the first caller and broke 23 existing tests. Rewritten as a plain function (`Object.defineProperty(window, 'matchMedia', { writable: true, value: (query) => ({...}) })`) so `restoreAllMocks` can't touch it. (b) `themeToggleHint` had previously only been updated in `en.ts`; real translations now ship in all 8 non-English locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW) describing the 3-state cycle without referencing the old sun/moon icon pair. (c) PR description reworded to honestly call out the sidebar cycle change as a behaviour change for every user of the toggle (`dark → light → system` now intercepts where users previously got `dark → light → dark`), with the persisted-preference-unchanged caveat made explicit. (d) New i18n key `nav.switchToSystem` with real translations across all 9 locales (`'Switch to system mode'` / `'Zum Systemmodus wechseln'` / `'システムモードに切替'` etc.). **Tests**: 11 new in `ThemeContext.test.tsx` (systemPreference inits from `matchMedia.matches`, change event updates state, resolvedMode follows explicit mode vs systemPreference per `mode` value, dark class applied based on resolved mode, `toggleMode` cycles dark→light→system→dark); 1 new in `Layout.test.tsx` (toggle button title attribute walks the cycle); 4 new in `SettingsPage.test.tsx` (all three buttons render, active green border keys off `mode`, click switches mode, click fires toast). 26 previously-broken tests in `AddNotificationModal.test.tsx` + `NotificationProviderCardStockAlerts.test.tsx` + `CameraTokensPage.test.tsx` pass again post-`setup.ts` fix. Frontend build clean (2682 modules); i18n parity green at 4995 keys × 9 locales (+1 from `switchToSystem`). Contributor handled the entire round-1 review (matchMedia mock, locale parity, PR honesty, full test coverage, toast parity, `.map()` refactor for the button group) in a single revision push, no follow-ups deferred. - **MQTT auth rate-limit on the virtual printer** — Bambuddy's VP exposes an 8-char access code via the slicer-facing MQTT server on port 8883. Without a rate limit the code is brute-forceable by anyone who can reach the VP's bind IP (LAN, Tailscale, or any other tunnel the user chose to expose). The new per-IP limiter records each failed CONNECT auth attempt and rejects further CONNECTs from that IP once 5 failures occur within a 60 s window. The window is sliding (not cumulative), recovers automatically after expiry — no manual unblock — and successful auth clears the IP's prior failure history so a user who fat-fingered their code 3 times then got it right isn't penalised on their next reconnect. Per-IP tracker uses `time.monotonic()` so wall-clock jumps can't extend or shorten the window unexpectedly. Constants `_AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5` and `_AUTH_RATE_LIMIT_WINDOW_SECONDS = 60.0` are module-level for ops tunability. 5 unit tests in `test_vp_mqtt_server.py::TestAuthRateLimit` pin the under-limit/at-limit/window-recovery/multi-IP/success-clears semantics. - **Per-slicer MQTT response routing for multi-slicer VP setups** — Pre-fix: when slicer A sent `extrusion_cali_get` (or any other bridge-forwarded command) to a non-proxy VP bound to a target printer, the printer's response was fanned out to **every** connected slicer — leaking slicer A's response into slicer B's command stream. Slicers ignore responses to sequence_ids they didn't send, but the leak is still wrong and could confuse multi-slicer-host setups (workstation + laptop both connected to the same VP). The fix records `sequence_id → originating client_id` in `SimpleMQTTServer._pending_requests` on the way out and looks it back up in `push_raw_to_clients` on the way in, routing the response only to that one client. Falls back to broadcast for printer-initiated unsolicited pushes (push_status etc. — every slicer expects these) and for sequence_ids the map never saw recorded (covers slicers subscribing mid-flight). Bounded at 256 entries with FIFO eviction so a slicer that sends commands without ever consuming responses can't leak memory. 6 unit tests in `test_vp_mqtt_server.py::TestPendingRequestRouting` cover seq-id capture across nested blocks, lookup-pops-entry semantics, FIFO eviction at cap, malformed-payload fallback, and broadcast on unrecorded seq. - **H2D Pro virtual-printer support (experimental — needs field confirmation)** — Added SSDP model codes `O1E` and `O2D` to `VIRTUAL_PRINTER_MODELS` and matching `09400A` serial prefixes to `MODEL_SERIAL_PREFIXES` so the H2D Pro shows up in the Add Virtual Printer model dropdown and advertises a model code distinct from H2D's `O1D`. The codes were transcribed from the project's model-codes reference but have not been validated against a live H2D Pro's SSDP response. Anyone with an H2D Pro who picks this from the dropdown should confirm BambuStudio recognises the VP correctly; if not, the code values need a one-line correction and a follow-up release. - **VP child-service readiness barrier** — Pre-fix: `VirtualPrinterInstance.start_server` spawned each child sub-service (FTP, MQTT, Bind, SSDP) as a `asyncio.create_task` and returned immediately. `is_running` then reported `True` even though the child sub-services' sockets were still in the gap between `asyncio.create_task(...)` and the inner `asyncio.start_server` returning. A caller racing the start (the diagnostic route, the VP-card UI poll, an integration test) could see `running=pass` while `port_ftps=fail`. Each child now exposes a `ready: asyncio.Event` that's set after the actual socket bind, and `start_server` awaits all of them with a bounded 5 s timeout. If a child hangs binding, the timeout logs a `Sub-service didn't bind within 5s: ...` warning and the VP continues — the existing task-tracking still catches the failure on the next iteration. The 5 s ceiling is well above any legitimate bind on healthy hardware; on a Pi 3 with a congested SD card it's tight but bounded. ### Changed - **Bug-report template: tightened fields + new Area dropdown to cut invalid-issue triage load** — 170 issues have been closed with the `invalid` label (61 of them in the last 30 days alone — roughly 1 in 5 of all closed issues), nearly always because the reporter hadn't run the in-app diagnostics or checked the documented troubleshooting page. The template now forces engagement with the tools that were already shipped. **Form changes** (`bug_report.yml`): (a) the "I ran the Connection Diagnostic" checkbox flipped from `required: false` to `required: true`, so the form blocks submission until the reporter has actually used the diagnostic (or knowingly lied — higher friction than reading the doc); (b) the Support Package textarea is now `required: true` instead of optional, with the field's prompt rewritten to "Drag the .zip here, or explain why you cannot attach one" so users without a working Bambuddy still have a path; (c) a new required "Troubleshooting steps already taken" textarea sits between Steps to Reproduce and the printer-model dropdown, asking which wiki pages were checked and which in-app diagnostics were run — empty answers can't submit, which produces either real evidence or an admission that nothing was tried (both of which are useful for triage); (d) the pre-form markdown intro now spells out the "search → wiki → diagnostic → support package" sequence with a citation of the 1-in-5 stat so reporters understand the *why* before they reach the fields; (e) the final-checks list grew from one to three required confirmations (searched issues + checked troubleshooting wiki + ran Connection Diagnostic for connection/printing/camera bugs), with the wiki-checked confirmation linking to the rendered troubleshooting page. **Bug categorization** (the gap that motivated the rewrite): the old single `Component` dropdown only carried `Bambuddy / SpoolBuddy / Both` — useless for area triage. Replaced with TWO required dropdowns: `Product` (Bambuddy / SpoolBuddy) and `Area` (15 options covering the actual feature surface — connection, dispatch, filament/AMS, slicer, VP, camera, archives, stats, queue, notifications, auth, updates, UI, integrations, SpoolBuddy kiosk, plus an Other escape hatch). **Auto-labeling** (`.github/workflows/auto-label-area.yml`): on every issue open/edit, an `actions/github-script@v7` step parses the Area dropdown out of the rendered issue body (matching the `### Area\n\nValue` block GitHub forms produce) and applies the matching `area:*` label. Tolerant of CRLF, the `_No response_` placeholder, and the issue-edit re-fire path (won't re-add an already-present label). Unrecognised Area values emit a `core.warning` so missed sync between the form and the workflow map shows up in Actions logs. Maintainer hand-off: 15 `area:*` labels need to be created once via `gh label create` (see commit message for the exact commands) — labels referenced by the workflow but missing in the repo cause the `addLabels` call to throw, so this prerequisite is load-bearing. Printer Model dropdown verified against `PRINTER_MODEL_MAP` in `backend/app/utils/printer_models.py` — all 13 current Bambu models present (X1 Carbon / X1 / X1E / X2D / P1S / P1P / P2S / A1 / A1 Mini / H2D / H2D Pro / H2C / H2S), no update needed. YAML syntax validated via Python `yaml.safe_load` for both the template and the workflow. - **VP virtual-printer FTP server: cmd_STOR streams chunks straight to disk instead of buffering the whole upload in memory** — Pre-fix: ``cmd_STOR`` accumulated every chunk in a ``list[bytes]`` and called ``write_bytes`` at the end. Peak RSS for a multi-GB ``.gcode.3mf`` (multi-plate dense prints) was ~2× the file size — chunks held + the ``b''.join`` of them — and could OOM-kill a low-memory host (Pi 3, low-end Synology, etc.). The streaming rewrite writes each 64 KiB chunk to ``file_path.open("wb")`` inline as it arrives, bounding peak memory at one chunk regardless of total upload size. Wire protocol unchanged — same ``150 → 226`` sequence, same destination path, no new verbs, no concurrency guard. The visible difference is that the destination file grows progressively rather than appearing all-at-once on completion; slicers don't ``LIST`` during ``STOR`` so this isn't observable. Same change adds a ``MAX_UPLOAD_BYTES = 4 GiB`` hard cap — a runaway or malicious client can no longer drive RSS or disk to exhaustion. On the cap path the partial file is unlinked so a slicer retry starts clean. 4 unit tests in ``test_vp_ftp_stor.py`` (happy-path bytes on disk + 226, cap-violation 426 + partial cleanup, mid-stream read error cleanup, MAX_UPLOAD_BYTES sanity floor). - **VP virtual-printer FTP passive port range widened from 50000-50100 (101 ports) to 50000-51000 (1001 ports)** — The original range was sized for a single VP. With multiple VPs each running their own FTP server, concurrent passive data connections compete for the 101-port pool and the bind-retry loop's 10 random picks can collide; 1001 ports gives headroom. Only affects the **non-proxy** path (``VirtualPrinterFTPServer.PASSIVE_PORT_MIN/MAX``). The proxy path's ``SlicerProxyManager.FTP_DATA_PORT_MIN/MAX`` stays at 50000-50100 because it pre-binds the printer-side range exactly. Docker bridge-mode users mapping the old range need to update to ``50000-51000:50000-51000`` — `docker-compose.yml`, `install/docker-install.ps1` warning, and the wiki (`docs/getting-started/docker.md`, `docs/features/virtual-printer.md` — port table, two UFW rules, two firewalld rules, Cloudflare-tunnel list, firewall troubleshooting line) all updated with "widened in 0.2.5" notes. Docker host-mode and bare-metal users are unaffected (no port mapping involved). The proxy-mode FTP-data row in the wiki stays at 50000-50100 because that path is unchanged. - **VP MQTT bridge sticky-keys: 7 more fields preserved across incremental pushes** — Pre-fix: when the bridge cached a real printer's ``push_status``, the very next 1 Hz incremental push (which only carries changed temps / fan / wifi_signal) wiped any field not in the sticky-keys allowlist. The cached state lost ``upgrade_state``, ``xcam``, ``hw_switch_state``, ``nozzle_diameter``, ``nozzle_type``, ``online`` and ``ams_status`` after a single tick — BambuStudio's Send pre-flight reads several of these (``upgrade_state.dis_state`` / ``force_upgrade`` in particular) and could refuse Send because the cached push said "unknown firmware state". Same shape as #1228 (storage indicators) and #1558 (live-progress fields) — the cached-branch field-shape parity, not a new mechanism. Sticky-keys carry-forward is now also a ``copy.deepcopy`` (was reference) so a future merge that mutates a carried-forward dict in place can't corrupt both copies. - **VP target-printer DHCP IP / serial refresh now restarts proxy VPs** — Pre-fix: when a target printer's IP changed (DHCP renewal, network reconfiguration), the running proxy VP kept forwarding to the stale IP forever because ``sync_from_db``'s "changed" predicate didn't compare ``proxy_ips`` against the running instance's ``target_printer_ip`` / ``target_printer_serial``. The user had to manually toggle the VP to refresh. Now ``sync_from_db`` re-evaluates the proxy target each cycle and restarts the VP when the IP or serial actually changes — same code path as a config change. If the target printer's DHCP lease cycles frequently this means more proxy restarts, but the alternative was silent breakage; documented in the release-notes for users on flaky-DHCP networks. - **VP queue_force_color_match setting takes effect immediately** — Pre-fix: toggling the per-VP ``Force exact color match`` setting via the UI silently no-op'd because ``sync_from_db``'s "changed" predicate didn't include the field. The user had to restart the process for the new value to land. The predicate now also checks ``queue_force_color_match`` so the running instance gets restarted on toggle. - **VP MQTT client session errors elevated from DEBUG to WARNING** — The outer ``except Exception`` in ``SimpleMQTTServer._handle_client`` was logging at DEBUG, which production deployments default to suppressing. Users reporting "slicer disconnects randomly" then had no signal to pass us. WARNING surfaces it. Inner handlers' expected parser/IO failures stay at DEBUG — only unexpected errors that would otherwise reach the outer catch get visibility. - **VP MQTT periodic status push now logs a one-line per-minute counter per active slicer connection (#1548 follow-up)** — ``_periodic_status_push`` emits ``1Hz status push: N pushes/min to `` at INFO level once per minute per connected slicer (silent when no slicer is attached). The 1 Hz status push was previously silent at INFO; when a reporter sent a support bundle showing an idle disconnect, there was no way to tell whether the push task was actually pushing to that connection or being eaten silently. The counter both confirms the task is healthy for a given client and gives us a concrete data point (N < 60 means pushes were dropped) when triaging future "slicer disconnects on idle" reports. No behaviour change to the push itself. ### Security - **WebSocket auth gate + audit-driven hardening sweep — A proactive auth-surface audit run surfaced one critical (`/api/v1/ws` broadcast every printer-status / archive / inventory event to anyone reachable on the HTTP port. All fixed in the same PR. - **API-key permission enforcement is allowlist-based** (reported by @vfxdev) — The three documented API-key scopes ("Read Status", "Manage Queue", "Control Printer") were enforced only inside the legacy `/api/v1/webhook/*` router; every other route used `require_permission_if_auth_enabled` which fell through to a 17-entry admin denylist for API keys and ignored the per-key scope flags. The structural failure modes: (a) any valid key, including one with every scope checkbox unticked, could call print start/stop/pause/resume, queue create/delete/reorder, archive reprint, and every `*_READ` endpoint outside the denylist; (b) `require_any_permission_if_auth_enabled` (`inventory.py`) and `require_ownership_permission` (`print_queue.py`, `archives.py`, `library.py`, `library_trash.py`) returned `None` for any valid key with zero scope check, granting full ownership-modify access to ~10 ownership-gated routes; (c) every new `Permission` enum value added to `core/permissions.py` since the denylist was written silently joined the "API-key-allowed" bucket — fail-open-by-construction, which is exactly how the surface grew over time. **Fix**: `core/auth.py::_check_apikey_permissions` now consumes a new `_APIKEY_SCOPE_BY_PERMISSION` allowlist that maps every non-admin `Permission` to exactly one scope flag on the `APIKey` row; unmapped permissions return 403 ("administrative operations") regardless of which flags are set; the helper is now invoked in all three previously-skipping dependencies. The denylist is retained as a redundant explicit "these are admin" marker plus drift-detection in tests, but the allowlist is the load-bearing check. **Two new scope flags** (per same-PR design discussion): `can_manage_library` (gates `LIBRARY_UPLOAD` / `LIBRARY_UPDATE_OWN` / `LIBRARY_DELETE_OWN` / `MAKERWORLD_IMPORT` — distinct trust level from queue management; rejected the "fold library upload into can_queue" shortcut) and `can_manage_inventory` (gates `INVENTORY_CREATE` / `INVENTORY_UPDATE` / `INVENTORY_DELETE` / `INVENTORY_FORECAST_WRITE` — required because SpoolBuddy kiosks write NFC scans, scale readings, and `/spoolbuddy/devices/{id}/system/command` + `/update` via INVENTORY_UPDATE under the prior denylist gap; 15+ kiosk routes depend on this scope). `CLOUD_AUTH` is now routed through the existing `can_access_cloud` flag (was unmapped → would have admin-denied; the router-level `_cloud_api_key_gate` already does this check, but the route-level dep now fails closed too for defence in depth). **Migration** (`core/database.py::run_migrations`, dialect-branched per [[feedback_sqlite_and_postgres_upfront]]): two new boolean columns added to `api_keys` with `DEFAULT TRUE`, one-shot backfilled to mirror `can_queue` (gated on a new `_api_keys_column_exists` check so the backfill runs only on the migration that adds the column — user-edited values on subsequent restarts are never clobbered). Backfill rationale: a key the operator created as "queue-only" was implicitly relying on the upload+queue and inventory-write workflows the queue scope already let through, so mirroring `can_queue` preserves the operator's intent; a hardened "read-only" key (`can_queue=False`) does NOT silently gain new writes on upgrade. The bundled SpoolBuddy CLI key is explicitly granted `can_manage_inventory=True` because the kiosk itself is the legitimate writer (NFC scan, scale reading, /system/command). **Structural drift backstop**: new `test_every_permission_has_a_classification` fails CI on any future `Permission` added to `core/permissions.py` without an entry in `_APIKEY_SCOPE_BY_PERMISSION` or `_APIKEY_DENIED_PERMISSIONS` — the previous denylist shape allowed silent surface growth, this catches it. **Tests** (`test_auth_apikey_rbac.py`): 78 new — pure-logic `_check_apikey_permissions` matrix covers every (Permission × scope-flag combo) outcome with cross-scope leakage assertions, the structural drift-detection guard, allowlist/denylist disjointness, scope-flag-has-permissions sanity, unknown-perm-string + empty-perm-list fail-closed cases, and the `require_any=True` semantics; the existing denylist-integrity test is updated to reflect that INVENTORY_CREATE/UPDATE are now allowlisted (not admin-only-by-omission) and that operations admin only via omission (PRINTERS_CREATE, LIBRARY_DELETE_ALL, LIBRARY_PURGE, DISCOVERY_SCAN) still 403 with a fully-flagged key. Full 5469-test backend suite green; backend ruff clean. **Frontend**: API-key create dialog gains "Manage Library" + "Manage Inventory" checkboxes with descriptions, the existing list view gains Library and Inventory badges, the cosmetic `apiKeyName`/save-toast flow is unchanged; `api/client.ts` `APIKey` / `APIKeyCreate` / `APIKeyUpdate` types extended. **i18n parity**: real translations for the 6 new keys (`manageLibrary` / `manageLibraryDescription` / `manageInventory` / `manageInventoryDescription` / `libraryBadge` / `inventoryBadge`) across all 9 locales per the [[feedback_translate_dont_fallback]] HARD RULE; parity script green at 5005 leaves × 9 locales. **Wiki** (`features/api-keys.md`): permissions table grows from 5 to 7 toggles with the new scopes and an updated "Principle of Least Privilege" examples list; upgrade notes call out the can_queue-mirroring backfill so operators understand why an existing "queue-only" key keeps uploading after upgrade (and why a "read-only" key still won't); a new explicit "Allowlist model since 0.2.4.5 (GHSA-r2qv-8222-hqg3)" callout documents the shift from denylist to allowlist with the exact previous failure mode (so the audit-trail isn't only in this CHANGELOG). **Out of scope / explicit choice**: did not refactor the SpoolBuddy kiosk routes to use a more semantically-accurate permission than `INVENTORY_UPDATE` for `/system/command` and `/update` (large blast radius across 15+ route decorators, and `can_manage_inventory` matches the trust dimension correctly); did not consolidate the bespoke `require_energy_cost_update` into the new allowlist (its narrow-scope semantics — bypass the SETTINGS_UPDATE denylist via `can_update_energy_cost` — predates this work and is still the right shape for that one electricity-price endpoint). - **Trivy DS-0026 (`Dockerfile.test` missing HEALTHCHECK): silenced via `HEALTHCHECK NONE`** — The test image runs `pytest` and exits; there is no long-running service to probe, so any HEALTHCHECK we added would be cargo-cult noise. `HEALTHCHECK NONE` is the documented Docker directive to explicitly opt out of any inherited healthcheck and is the way Trivy expects projects to signal "this image is not a service." Closes code-scanning alert #813. - **VP access codes now compared with `hmac.compare_digest` (constant-time)** — Pre-fix: both `FTPSession.cmd_PASS` and `SimpleMQTTServer._handle_connect` used Python's `==` operator on the 8-char access code. Constant-time comparison closes the timing-side-channel without changing the protocol surface. Same auth, no UX change. - **VP MQTT brute-force rate-limit per source IP** — 5 failed CONNECT attempts within a 60 s sliding window block further auth attempts from that IP for the rest of the window. Auto-recovers — no manual unblock. Constants `_AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5` / `_AUTH_RATE_LIMIT_WINDOW_SECONDS = 60.0` are module-level for ops tunability. See Added section for full description. - **VP `access_code` no longer leaked in DEBUG logs** — Pre-fix: `PUT /virtual-printers/{id}` logged `body.model_dump(exclude_unset=True)` at DEBUG, which dumped the plaintext access code whenever the user saved a new one. Now the field is redacted (`***`) before the log emission. Violation surfaced by no-secrets-in-logs audit; not exploitable in the field (DEBUG is off by default) but is exactly the kind of leak the rule exists to prevent. - **VP FTP upload capped at 4 GiB (DoS guard)** — `cmd_STOR` now rejects an upload that crosses `MAX_UPLOAD_BYTES = 4 GiB`, deletes the partial file, and replies 426. Without the cap a runaway or malicious client could drive RSS or disk to exhaustion; 4 GiB is well above any realistic multi-plate `.gcode.3mf`. Same code path adds the streaming rewrite (see Changed section for details). ### Security - **Path-traversal hardening across the upload / import / file-write surface (routes + services); fifth CI backstop ships alongside** — A private path-traversal report against `POST /api/v1/projects/import/file` traced two attacker-controlled strings being joined to `library_dir` with no resolve + containment check: (a) `linked_folders[*].name` from the request's `project.json` ("Vector A" — an absolute path in this field collapsed `library_dir / "/anywhere"` to `Path("/anywhere")` because pathlib discards the left side when the right is absolute, letting the next `write_bytes` land anywhere the backend could write), and (b) per-entry `zf.namelist()` paths from the ZIP itself ("Vector B" — ZIP filenames carry `..` segments by spec and the join `library_dir / folder_name / relative_path` had no per-component check). Concrete escalation: drop a `.pth` file into the venv's `site-packages` directory for code execution on next service restart; overwrite the JWT signing-secret file to forge an admin token; overwrite `~/.ssh/authorized_keys` or `~/.bashrc` on native installs. **Fix is structural, not just patch the diff** (per [[feedback_dont_dismiss_preexisting]]). New `backend/app/utils/safe_path.py::safe_join_under(parent, *parts)` helper joins under a trusted parent, resolves both sides, asserts `is_relative_to(parent.resolve())`, and rejects up-front empty / null-byte / absolute path components. Wired into `import_project_file` at both vectors. **Adjacent fix from the routes audit**: `GET /api/v1/archives/{id}/photos/{filename}` had NO validation on `filename` and FileResponse-served arbitrary paths — the existing DELETE endpoint at least had a membership check against `archive.photos` (which is UUID-generated on upload), but GET shared neither the check nor any traversal guard. Both GET and DELETE now route through `safe_join_under` for defence-in-depth on top of the membership check. **Second adjacent fix from the services audit**: `ArchiveService.attach_timelapse(archive_id, data, filename)` in `backend/app/services/archive.py:1456` wrote `archive_dir / filename` where `filename` ultimately comes from either a printer's FTP listing (compromised-printer threat model — the printer is part of the trust surface) or the `?filename=...` query param on `POST /api/v1/archives/{id}/timelapse/select`. A malicious printer that returns a directory listing entry with `..` segments could write the timelapse bytes outside the archive directory; the `f.get("name") == filename` gate in the route did not prevent it because the gate is satisfied by whatever the printer claims is on disk. `attach_timelapse` now routes through `safe_join_under(..., http=False)` and returns `False` (logging the rejection) when the join would escape — matching the existing not-found contract of the function rather than raising 400 from inside a background task. **Audit sweep methodology**: AST-walked every Python file under `backend/app/api/routes/` AND `backend/app/services/` for `Path / Name` shapes (the exact shape that produced the original report). 25 additional route-layer sites and 8 additional service-layer sites confirmed safe case-by-case (UUID-generated filenames written by Bambuddy itself, `_safe_filename(...)` / `Path(arg).name` basename-stripped inputs, `os.walk`-discovered names, denylist + format-validated backup names, hardcoded constants iterated through a tuple, DB-stored paths whose write origin already goes through a resolved-and-containment-checked helper). Each safe site got a `# SEC-PATH-OK: ` marker so future audits can trust the inline guard at a glance. Six pre-existing safe-with-marker sites (`library.py` external upload, `archives.py` timelapse output, `projects.py` attachment download/delete, `settings.py` backup extractall) carry the same marker shape. **Fifth CI backstop** `test_route_path_arithmetic_is_safe_joined_or_marked` (`backend/tests/unit/test_no_unsafe_path_joins.py`) AST-walks every Python file in `backend/app/api/routes/` AND `backend/app/services/` and fails the build on any ` / ` join that doesn't either route through `safe_join_under` or carry the marker on the join line. Joins matching the higher-structure shapes (Attribute access, Subscript, f-string, `str(...)` call) are categorically different and out of scope — those are caught by the broader audit sweep, not the regression backstop. The services layer is in scope because it receives values from the routes verbatim AND from external sources Bambuddy has no control over (the printer FTP-listing case above). **Tests**: 17 unit tests for `safe_join_under` covering every escape vector (absolute path, Windows abs path, `..` segments, embedded `..`, null byte, empty string, no parts, non-str, plus legitimate nested-path round-trip); 4 integration tests against `POST /api/v1/projects/import/file` exercising the full FastAPI stack with the verbatim shape from the report (absolute path in `folder_name` → 400 + filesystem assertion that the target file doesn't exist; `..` in `folder_name` → 400; `..` in `relative_path` → 400; legitimate nested ZIP still imports cleanly to guard against the fix being over-strict); 3 unit tests against `ArchiveService.attach_timelapse` exercising the compromised-printer threat model (filename with `..` segments → returns False + no file at the escape target; absolute filename → returns False + no file at `/tmp`; legitimate `timelapse_YYYY-MM-DD_HH-MM-SS.mp4` → returns True + file lands inside archive_dir, guarding against the fix being over-strict). **SECURITY.md** gains a fifth rule + a fifth row in the CI-test mapping table; the rule explicitly names the printer FTP-listing case as in-scope to set the expectation for future services-layer audits. Full 5500+ test backend suite green; ruff clean. ### Fixed - **Webhook printer-status / stop / cancel routes 500'd on every connected printer because the route treated the PrinterState dataclass as a dict (#1584, reported via in-app bug report)** — Reporter saw `GET /api/v1/webhook/printer/{id}/status` return `500 Internal Server Error` with a valid API key carrying the `read_status` scope, while `GET /api/v1/system/info` returned 200 with the same key — so auth and routing were fine, the handler itself was crashing. Cause: `printer_manager.get_status(printer_id)` returns a `PrinterState` dataclass (`backend/app/services/bambu_mqtt.py`), not a dict. The route at `webhook.py:266-270` called `status.get("connected", False)`, `status.get("state")`, `status.get("current_print")`, `status.get("progress")`, `status.get("remaining_time")` — every one raised `AttributeError`, which Starlette surfaced as a generic 500. Reporter's id-1 (printer exists) returned 500; non-existent ids returned 404 — exactly because the early `Printer not found` branch fired before reaching the crash. Same shape in two adjacent routes: `webhook_stop_print` (`POST /printer/{id}/stop`) and `webhook_cancel_print` (`POST /printer/{id}/cancel`) checked `status.get("connected")` / `status.get("state")` for their precondition gates. 8 crash sites total across the three routes. **Fix**: every `status.get("X", default)` replaced with attribute access (`status.X if status else default`); Pydantic response schema unchanged. `PrinterState`'s dataclass defaults cleanly cover the `status is None` branch (printer registered but never connected — the route now returns 200 with `connected=false, state=null, …` rather than crashing). **Tests** (`backend/tests/integration/test_webhook_printer_status.py`): 7 new — status route returns 200 with the dataclass attributes mapped into the response (regression for the exact #1584 shape); status route returns 200 with sensible defaults when `get_status()` returns None; status route returns 404 for a non-existent printer (control case proving the auth path is unaffected); stop route returns 503 when disconnected (pre-fix would have 500'd here); stop route returns 409 when state is not `RUNNING`; cancel route returns 503 when disconnected; cancel route returns 409 when state is not `RUNNING`/`PAUSE`. Runtime-verified end-to-end against a live PG-backed instance before and after: same key + same printer id, 500 before the patch and 200 with the correct payload after. Full backend suite + ruff clean. - **Path-traversal CI backstop now recognises markers on the closing-paren line (project-wide convention)** — `test_no_unsafe_path_joins.py::test_route_path_arithmetic_is_safe_joined_or_marked` AST-walks every Path-arithmetic site in `api/routes/` + `services/` and demands either `safe_join_under(...)` or a `# SEC-PATH-OK: ` marker. The marker-detection helper only scanned the BinOp's own line range (`lineno..end_lineno`), but the project's convention puts the marker on the line of the wrapping closing paren — one past `end_lineno`. The backstop flagged 30 already-marked, already-safe sites as findings, masking the fact that the post-GHSA marker work is complete. The helper now peeks one line past `end_lineno` IF that line begins with a continuation token (`)`, `]`, `}`, `,`), capturing exactly this convention without giving a free pass to a marker on a wholly unrelated next statement. 5 new tests in `TestMarkerDetection` pin the contract: marker on the BinOp line recognised; marker on the closing-paren line recognised; an unrelated marker on a later statement does NOT silence; a marker on a non-continuation line right after the BinOp does NOT silence; no marker anywhere is still flagged. Integration test now passes against the existing tree — 30 findings → 0 — with no changes to any guard / sanitisation in routes or services. - **Deleted local profiles no longer linger in the SliceModal preset dropdown; new manual "Refresh" button surfaces cloud-side deletions without waiting for the 5-minute cache (#1581, reported by @lloydjohnson)** — Reporter saw deleted local AND cloud profiles still appearing in the slice menu after removing them. Two distinct causes wired together. **Local half (real bug)**: `LocalProfilesView`'s import and delete mutations invalidated `['localPresets']` (the Local Profiles management view's own query) but not `['slicerPresets']` — the SliceModal reads from the unified `/slicer/presets` endpoint via a separate React Query key (`SliceModal.tsx:425`, `staleTime: 60_000`), so a freshly-deleted preset kept rendering in the dropdown until the modal's 60 s staleTime elapsed plus a refocus / remount. The backend was correct end-to-end (`delete_local_preset` removes the DB row, `get_db()` auto-commits, `_fetch_local_presets` reads fresh from DB with no backend cache). Both mutations now also invalidate `['slicerPresets']` so the next modal open shows the current set. **Cloud half (by-design backend cache + new opt-in bypass)**: `_fetch_cloud_presets` keeps a 5-minute per-(user, token) in-process cache balancing "users see their freshly-saved presets quickly" against "a busy install doesn't hit Bambu Cloud once per modal open" (`slicer_presets.py:69`). The user deletes cloud presets in Bambu Studio / Bambu Handy, not in Bambuddy, so there's no event hook to invalidate on — the cache only refreshes when the TTL expires. Rather than shorten the TTL (which would effectively rate-limit the cloud for every user), the listing endpoint gains an opt-in `?refresh=true` query param that bypasses BOTH the cloud cache and the 1-hour bundled-preset cache for that one call; the fresh result is still written back so subsequent normal callers still hit cached responses. **New SliceModal "Refresh" button**: lives in the preset section header next to the cloud-status banner, calls `getSlicerPresets({refresh: true})` and writes the fresh slots into the `['slicerPresets']` cache via `queryClient.setQueryData` (so the spinner disappears immediately rather than triggering a second refetch). Spins the `RefreshCw` icon while in-flight; disabled during a slice enqueue so users can't fire it twice. **i18n**: real translations for `slice.refreshPresets` + `slice.refreshPresetsTitle` (action label + tooltip) across all 9 locales per the [[feedback_translate_dont_fallback]] HARD RULE; parity script green at 5007 leaves × 9 locales. **Tests**: 2 new backend in `test_slicer_presets.py` (`refresh=True` re-hits Bambu Cloud even with a warm cache + still writes the fresh result back for the next normal call; same shape for `_fetch_bundled_presets`); 1 new frontend in `LocalProfilesView.test.tsx` asserts the delete flow invalidates `['slicerPresets']` in addition to `['localPresets']` via a spied QueryClient. Full backend suite + frontend vitest + ruff + eslint + i18n parity green. - **STL thumbnail noise on first generation: matplotlib cache + font_manager scan (reported by @maziggy)** — On first STL upload, three matplotlib-internal log lines surfaced: `WARNING [matplotlib] /opt/claude/.config/matplotlib is not a writable directory` (Bambuddy's `$HOME` isn't writable for the default config path so matplotlib fell back to `/tmp/matplotlib-XXXXXX`), `INFO [matplotlib.font_manager] Failed to extract font properties from NotoColorEmoji.ttf` (matplotlib doesn't support the COLR/COLR1 emoji format; this is per-font), and `INFO [matplotlib.font_manager] generated new fontManager` (the cache was rebuilt). Because the fallback was `/tmp`, every host reboot lost the cache and the font scan ran again. **Fix is in `stl_thumbnail.py` before the matplotlib import**: (a) `_configure_matplotlib_cache()` sets `MPLCONFIGDIR` to `settings.base_dir / .cache / matplotlib` (mkdir'd if missing) so the cache persists across container restarts and the writable-dir warning never fires; respects an externally-set value so operators who chose their own path aren't overridden; best-effort with a debug fallback if settings can't be imported or the mkdir fails. (b) `logging.getLogger("matplotlib.font_manager").setLevel(WARNING)` at module import demotes the per-font INFO scan so the first cold start (before the cache is populated) doesn't surface a multi-line matplotlib preamble. **Tests**: 3 new in `test_stl_thumbnail.py` — the font_manager logger is at WARNING after module import; `_configure_matplotlib_cache` creates the directory under `base_dir` and sets `MPLCONFIGDIR` to point at it; an externally-set `MPLCONFIGDIR` is preserved verbatim. - **Bulk-upload ZIPs of stub / empty STL files no longer spam the log with thousands of warnings (reported by @maziggy)** — Uploading a ZIP containing many minimal STL stubs (e.g. the 24-byte `solid test\nendsolid test` shape) emitted one `WARNING [backend.app.services.stl_thumbnail] Failed to load STL or empty mesh: ` per file. The warnings were technically correct — `trimesh.load(...)` returned a valid Mesh with zero vertices, the safeguard at `stl_thumbnail.py:54` matched, and the function returned None so the library entry got created without a thumbnail — but the volume turned a successful ZIP upload into a journal full of WARNING lines. **Two-step fix**: (1) the per-file message at `stl_thumbnail.py:55` demoted from `logger.warning` to `logger.debug`; this is a per-file content observation, not an actionable error, and the caller already handles None correctly. The branch now catches only the rare "large enough but trimesh still can't parse it" case, still visible in debug logs without spamming production. (2) New module constant `MIN_USABLE_STL_BYTES = 200` (binary STL with one triangle = 80B header + 4B count + 50B triangle = 134B; ASCII STL with one triangle ≈ 150B; 200 is a safe floor below any real STL). Three thumbnail call sites in `library.py` (extract_zip_file ZIP entry path, single-file upload, `_backfill_external_stl_thumbnails`) pre-skip files below this size BEFORE calling `generate_stl_thumbnail`, so stubs / placeholders / corrupted files never enter the trimesh pipeline at all. **What this does NOT change**: behaviour is identical for any real STL — generation still runs, MAX_VERTICES still triggers simplification at 100k vertices for the 256×256 thumbnail render, large files still get thumbnails. **Tests**: 2 new in `test_stl_thumbnail.py` — one verifies `MIN_USABLE_STL_BYTES` sits above the smallest binary (134B), the smallest ASCII (150B), and the reporter's 24-byte stub case; the other writes the verbatim 24-byte stub from the bug report, calls `generate_stl_thumbnail`, and asserts no `WARNING`-level "empty mesh" record appears in `caplog`. Full backend suite green; ruff clean. - **Bambu Cloud sign-in failures caused by an upstream Cloudflare challenge now surface an actionable message instead of "Invalid response from Bambu Cloud" (#1575, reported by @cliveflint)** — Reporter hit "Invalid response from bambulabs when trying to sign in with authenticator pass code" on a Pi (UK network). Log showed three back-to-back `POST /api/sign-in/tfa` calls all returning Cloudflare's "Just a moment..." HTML interstitial instead of JSON; `backend/app/services/bambu_cloud.py::verify_totp` caught the `json.JSONDecodeError` and returned the opaque "Invalid response from Bambu Cloud" message. **Root cause is Cloudflare-side, not Bambuddy**: a curl from this machine with the same honest `Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)` UA at 2026-06-02 returned a clean `HTTP/2 400 {"code":5,"error":"Login failed"}` JSON — same UA, same headers, different network. CF's bot management appears to flag conditions (per-IP / TLS-fingerprint / rate / transient mitigation window) that don't reproduce from us. No reliable way to *prevent* the challenge from our side without browser impersonation, which is explicitly off the table per the 2026-05-12 compliance audit. **Fix is diagnostic, not bypass**: new `_detect_cloudflare_challenge(response) -> str | None` helper inspects the failed-parse response for CF markers (`"Just a moment..."` in body, `"challenges.cloudflare.com"` in body, HTTP 403 with `cf-mitigated` header, HTTP 503 with `cf-ray` header) and returns a message that attributes the block to Bambu Lab's Cloudflare protection, suggests waiting a few minutes, and tells the user that signing in to bambulab.com from a browser on the same network usually clears the challenge. Wired into all three JSON-parse sites: `login_request`, `verify_code`, and `verify_totp` — previously only `verify_totp` had a defensive catch; `login_request` and `verify_code` let the parse error bubble to `BambuCloudAuthError` with `"Expecting value..."` as the detail, which surfaced as a generic 401 in the UI. **Tests**: 8 new in `TestCloudflareChallengeDetection` (`backend/tests/unit/services/test_bambu_cloud.py`) — direct helper tests for each of the four CF markers, a negative case (real JSON 400 with `cf-ray` header from the actual successful curl response above is NOT misclassified as a challenge so the application-level "Login failed" still surfaces), an attribution check (message must name "Cloudflare" and "bambulab.com" so users can act on it), and full-stack tests covering all three call sites with the verbatim interstitial fragment from the reporter's log. The existing `test_verify_totp_cloudflare_blocked` updated to assert the new actionable message. Full 5486-test backend suite green; backend ruff clean. - **OIDC auto-provisioning now reads the standard `email` claim for `User.email` when `Email Claim` is set to a non-email identity claim (#1569, reported by @anderl1969)** — Reporter configured Authentik with `Email Claim = preferred_username` to drive username from the preferred_username claim and expected the standard `email` claim (which the ID token also carries) to populate the user's email field. Result: username was correctly set from `preferred_username`, but `User.email` came out empty. Cause: `backend/app/api/routes/mfa.py::_resolve_provider_email` reads only `claims[provider.email_claim]`. With `email_claim="preferred_username"` and `preferred_username="jdoe"`, the value fails the SEC-2 email shape check (no `@`) and returns `None`. The auto-create-users branch then constructs `User(email=None, …)` and stores `UserOIDCLink(provider_email=None)` even though `claims["email"]` carries a perfectly valid `jdoe@example.com`. **Fix**: new helper `_resolve_standard_email_for_user_record(provider, claims, provider_sub)` reads the standard `email` claim independently and applies the same Fall A/B logic (shape check, `require_email_verified` strict / permissive split, explicit `email_verified=False` drop). The auto-create-users branch in `oidc_callback` now resolves `user_email_for_storage = provider_email or _resolve_standard_email_for_user_record(...)` and uses that for both `new_user.email` and the `UserOIDCLink.provider_email` record. **Scope is deliberately narrow**: the fallback is invoked only when `provider.email_claim != "email"` AND the primary resolver returned `None` AND the auto-create-users branch is taken. The auto-link-existing-accounts gate above remains on the primary `provider_email` — it does NOT consult the fallback. This preserves every existing GHSA-shape guard: Fall-B (`email_claim='email'` + `require_email_verified=False`) is still rejected at schema level when paired with auto-link; Fall-C (custom claim) auto-link still depends on the custom claim's shape, never on the standard `email` claim. New email fallback path runs the same shape + `email_verified` enforcement as Fall-A/B for the standard `email` claim, so an attacker-controlled IdP that sets `email_verified=False` or sends a malformed value gets dropped exactly like it would on the primary path. **Tests**: 4 in `TestOIDCStandardEmailFallback` (`backend/tests/integration/test_mfa_api.py`) — `email_claim=preferred_username` with both claims present → username from `preferred_username`, email from standard `email`; `email_claim=preferred_username` with no standard `email` claim → email stays `None` (behaviour unchanged); standard `email` with `email_verified=False` → fallback drops, email stays `None`; `email_claim="email"` (default) with `email_verified` absent → fallback path does NOT fire (Fall-A semantics preserved). Full 5478-test backend suite green. Backend ruff clean. - **Sliced `.gcode.3mf` files now render in the 3D preview and expose a Preview-3D action in the file row (#1543, reported by @Vlado-Tarakan)** — Reporter exported a multi-plate `.gcode.3mf` from Bambu Studio to the shared folder Bambuddy watches and the 3D preview tab came up empty; if he re-uploaded the same file via the file manager, the preview worked. Root cause: two paths classify `file_type` differently. `backend/app/api/routes/library.py:1343-1348` (the shared-folder scan path) does a compound-extension check and tags the file `gcode.3mf`; the upload path at the same file's `1588` does a single `ext[1:]` and tags it `3mf`. Then `frontend/src/components/ModelViewerModal.tsx:71-73` had `hasModel = normalizedType === '3mf' || 'stl'` and `hasGcode = normalizedType === 'gcode' || '3mf'` — neither matched `gcode.3mf`, so the capabilities object landed with both flags false and the modal rendered an empty bed. `FileManagerPage.tsx:858` also gated the Preview-3D context action on `file_type === '3mf' || 'gcode' || 'stl'`, so for shared-folder files the entry didn't even appear, and the type pill at `765-770` had no colour case for `gcode.3mf` so it fell through to the generic gray. **Fix** (frontend-only, no backend churn): `ModelViewerModal.tsx` introduces an `isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf'` predicate used in two places — the capabilities branch (`hasModel = isThreeMfFamily || 'stl'`, `hasGcode = isThreeMfFamily || 'gcode'`) and the plates-loading branch that previously hard-gated on `!== '3mf'` and would have returned `setPlatesData(null)` for the shared-folder file. `FileManagerPage.tsx` adds `gcode.3mf` to the Preview-3D action gate and shares the gcode blue type-pill colour so sliced-output files are visually distinguishable from source 3MFs. The compound `gcode.3mf` classification on the backend is intentionally preserved — it carries useful "this is a sliced output" semantics that other UI surfaces could use later. The `canOpenInSlicer` and `sliceableType` checks at `ModelViewerModal.tsx:269, 277-280` are deliberately left alone — a sliced output isn't openable in the slicer, and `sliceableType` already explicitly excludes `.gcode` and `.gcode.3mf` per the comment "the file type can't be sliced". **Out of scope** (separate Bambu-Studio format limitation, not a Bambuddy bug): Vlado's secondary observation that the upload-path 3D preview "shows only one plate" even though his project has 5 plates — Bambu Studio's `.gcode.3mf` export contains the g-code and model data for the active plate only, not the entire multi-plate project. The print picker enumerates plates via `gcode_*.gcode` entries inside the zip (a separate code path), which is why the user can still pick the plate at print time. The empty-bed fix is the data point that closes the user-visible bug. **Tests**: existing full 2043-test frontend suite green; no test asserted on the unsupported `gcode.3mf` capabilities branch (the change is additive — `3mf` and `stl` and `gcode` behaviours are unchanged). Frontend build clean. - **Connected-edge reconciliation closes the missed-PRINT-COMPLETE loop that produced ghost replays on smart-plug power cycles (#1542 follow-up, reported by @vixussrl-ui)** — Reporter ran a fresh trace after the doubled-extension fix landed and found a distinct second cause behind his ghost prints, hitting 4-of-4 of his A1s. Timeline: 22:50 PRINT START → print runs all night → MQTT disconnects multiple times (A1's keepalives are unstable on his network) → print finishes during one of those disconnect windows so PRINT COMPLETE is never observed → smart plug cuts power on idle → power resumes for the next scheduled print → firmware auto-replays the leftover `.3mf` from the SD card → Bambuddy reconnects to a fresh PRINT START for the ghost. The existing IDLE-after-RUNNING completion check at `backend/app/services/bambu_mqtt.py:3022` was meant to catch the simple disconnect-then-finish case via `_previous_gcode_state` preserved across reconnects, but with multiple disconnect/reconnect cycles + a smart-plug power-off that Bambuddy can't distinguish from any other transient drop, the IDLE window that branch needs simply never reaches it. The SD `.3mf` lingers, the firmware ghost-replays every power cycle, and the loop repeats until the operator notices. **Fix**: a new connected-edge reconciliation pass — new `reconcile_stale_active_prints(printer_id)` in `backend/app/main.py` queries archives in `status="printing"` for the printer at MQTT (re)connect time and synthesises `on_print_complete(status="aborted")` for any whose print can't actually be running anymore. The decision is made by a pure `_is_active_archive_stale(archive, state)` function with three triggers: (1) current printer state is terminal (IDLE / FINISH / FAILED) — covers the clean disconnect-then-finish case the existing #3022 branch was already trying to handle; (2) printer is running but with a different `subtask_id` than the archive — Bambu firmware mints a fresh `subtask_id` for each print including the ghost-replay it runs after a power cycle, so a mismatch is unambiguous evidence the in-DB archive is no longer the print on the printer; (3) printer is running but `subtask_name` is empty — the printer doesn't know what it's running, archive reference is broken. PAUSE / PREPARE / SLICING / RUNNING with matching subtask are intentionally left alone — false positives there cost a single misreported "aborted" status that the real PRINT COMPLETE would have overwritten anyway, while a false negative is the ghost-print loop being reported. The synthesised `on_print_complete` reuses the existing chain (SD cleanup, status update, usage tracker, notifications) — no reimplementation, no duplicate event when real completion later fires (the second call sees `status != "printing"` and falls through). Status `"aborted"` is the conservative label; we have no progress evidence to promote to `"completed"`. **Wiring**: new `_printer_reconciled_since_connect: dict[int, bool]` edge tracker at module scope, checked at the start of `on_printer_status_change` — when `state.connected` flips False → True (which covers both Bambuddy startup with no prior connection AND a mid-session MQTT reconnect), reconciliation fires exactly once for that connection. Setting the edge to True BEFORE the spawned task starts prevents concurrent status updates within the same connection from re-triggering it. **Concurrency**: reconciliation runs as `asyncio.create_task` so it doesn't block the WebSocket dedup / broadcast logic that on_printer_status_change is the hot path for. **Ghost-print collateral worth being explicit about**: if the ghost is already running when reconciliation fires, the synthesised SD-cleanup will hit 550-file-locked (firmware locks the file during print, same cause as the #1542 first case). The cleanup retries 3× then logs "lingering" — same as any other in-print cleanup attempt. The ghost runs to completion, its own end-of-print cleanup deletes the file, and the next power cycle has nothing to replay. The loop breaks even when reconciliation can't physically delete the file mid-ghost. A perfect cancel would require sending a `print_stop` MQTT command to the printer, which is invasive and explicitly out of scope. **Tests**: 21 in `test_reconcile_stale_active_prints.py` — `TestIsActiveArchiveStale` covers all three stale triggers with case-insensitive state matching, the four healthy-no-op cases (RUNNING / PAUSE / PREPARE / SLICING with matching subtask), the IDLE-overrides-subtask-match precedence, and the missing-subtask_id edge cases that fall through to the subtask_name check. `TestReconcileStaleActivePrints` covers the orchestrator: no-status, disconnected-status, and no-active-archives all short-circuit; a stale archive produces a synthesised `on_print_complete(status="aborted", _reconciled=True)` payload with the archive filename; a healthy in-flight archive doesn't fire any completion; an exception inside one archive's synthesis doesn't block the rest or propagate to the caller. Full 5399-test backend suite green (5378 + 21 new). Backend ruff clean. - **Fallback-archive MQTT filament extraction now actually fires for real prints (#1533 follow-up, reported by @JmanB52D)** — Reporter updated to 0.2.5b1 expecting the #1533 fix to populate filament fields on his P2S virtual-printer prints when the .3mf is locked. His support bundle showed Bambuddy still creating fallback archives with NULL filament fields even though the print-start log line proved AMS-0-T0 had PETG loaded at the moment the helper should have read it (`AMS 0: T0(type=PETG, color=FFFFFFFF, …)`). Cause: the #1533 helper `_extract_filament_data_from_mqtt(data)` in `backend/app/main.py` only looked at `data["ams"]`, but the dict that `on_print_start` actually receives at runtime is the wrapper shape `{"filename", "subtask_name", "remaining_time", "raw_data": , "ams_mapping"}` that `backend/app/services/bambu_mqtt.py:2971-2980` constructs — so `data["ams"]` was undefined on every real call and the helper silently returned `{}`, leaving the fallback archive's `filament_type` / `filament_color` NULL. The 15 unit tests that shipped with #1533 all passed the bare inner shape directly and never exercised the callback wiring, so the regression slipped through the green build. **Fix**: the helper now resolves `data["raw_data"]["ams"]` first (the callback shape) and only falls back to `data["ams"]` when the wrapper isn't present (preserves the inner-shape callers from the existing tests). Defensive: a non-dict `raw_data` (e.g. partial MQTT decode failure) falls through to the inner lookup instead of crashing. **Tests**: 5 new in `TestOnPrintStartCallbackShape` (`backend/tests/unit/test_fallback_archive_mqtt_filament.py`) — wrapper payload with ams_mapping resolves to the inner data; wrapper with no ams_mapping lists all loaded slots; the existing inner-shape callers still work after the additive wrapper lookup; missing `raw_data` returns `{}` instead of raising; junk `raw_data` (string) doesn't shadow a present inner `ams`. Full 5378-test backend suite green. Backend ruff clean. **What this does NOT fix**: per-filament gram usage still needs the actual .3mf — the printer locks it during print (P-line firmware behaviour, not a Bambuddy bug), and the existing 19 FTP candidate paths + directory probes are expected to 550 in that window. Per-print filament type and colour are the data point that drives the AMS-expansion planning the reporter explicitly called out, so this is the fix that moves the needle for him. - **Assigning a spool no longer shows a profile-mismatch warning when only the slicer profile differs, and the warning now states the AMS slot will be reconfigured (#1552, reported by @anthonyma94)** — Reporter assigned a spool to a slot whose stored slicer profile (e.g. "Bambu PLA Matte") differed from the new spool's profile (e.g. "Bambu PLA Basic"), got a warning popup with only Cancel / Assign Anyway, and was under the impression that confirming the popup just linked the spool in Bambuddy's DB without touching the AMS — i.e. that he then had to manually open Configure AMS Slot to push the new profile to the printer. The auto-push has actually been in place since the assign route existed: `backend/app/api/routes/inventory.py::assign_spool` calls `apply_spool_to_slot_via_mqtt` after upserting the SpoolAssignment row, which publishes both `ams_filament_setting` (tray_info_idx, tray_sub_brands, color, temps) and `extrusion_cali_sel` (K profile) over MQTT, and `backend/app/api/routes/spoolman_inventory.py::assign_spoolman_slot` does the same on the Spoolman side. The only short-circuit is when the firmware explicitly reports the slot empty (`tray_state ∈ {9, 10}`), in which case `main.py::on_ams_change` deferred-replays the configure as soon as a spool appears. So the popup was creating friction without revealing what it actually did. **Two changes**: (1) `AssignSpoolModal.tsx` + `spoolbuddy/AssignToAmsModal.tsx` no longer fire the mismatch popup for *profile-only* mismatches — `if (materialMatchResult !== 'exact')` replaces the old `materialMatchResult !== 'exact' || !profileMatches`, and the `'profile'` member is dropped from the `mismatchType` union (the standalone profile branch in both popup render bodies is removed as dead code). Material mismatch — where Bambu firmware can refuse the print because the type is wrong — still warns. (2) Every firing warning (material, partial, material+profile, partial+profile) now appends a new line via the new `inventory.assignReconfigureNote` i18n key: "The AMS slot will be reconfigured to use the spool's profile." This makes the Assign Anyway button's effect explicit instead of leaving users to guess. **i18n**: real translations across all 9 locales per [[feedback_translate_dont_fallback]]; parity script clean at 4999 leaves per locale. **Tests**: existing 14 `AssignSpoolModal` + 7 `AssignToAmsModal` tests pass unchanged — no test asserted on the profile-only popup firing. Frontend build clean, full 2043-test suite green. **Open follow-up**: if anthonyma94 confirms after this change that his slot *still* shows the old profile after Assign Anyway, the real bug is in `apply_spool_to_slot_via_mqtt`'s tray_info_idx / setting_id resolution for his specific spool shape — would need his spool's `slicer_filament` value plus the live tray state to diagnose. - **Transparent / clear filament now selectable and rendered as transparent end-to-end in the built-in inventory (#1545, reported by @Synec5, confirmed by @CMW-ISS)** — Reporter wanted to select a transparent filament colour in the spool editor; CMW-ISS independently confirmed on v0.2.5b1 that AMS-detected transparent spools were silently labelled "Black" in the filament-mapping dropdown because the colour name resolver dropped the alpha byte and the underlying RGB `000000` HSL-bucketed to "Black". Spoolman already supported 8-digit `RRGGBBAA` hex; the built-in inventory didn't. Five distinct sites collapsed alpha → 6-char RGB and had to be fixed together: (a) `frontend/src/utils/colors.ts` — `hexToColorName`, `getColorName`, `resolveSpoolColorName`, and `isLightColor` now short-circuit to `"Clear"` when the input is 8 chars with alpha `00`, before either the catalog lookup or the HSL fallback can mislabel transparent as black; `isLightColor` returns `true` for clear so text contrast matches the light/mid-gray checkerboard underlay the swatch paints. (b) `frontend/src/utils/amsHelpers.ts::normalizeColor` no longer unconditionally strips the alpha byte — it preserves `#RRGGBBAA` when alpha < `FF` so the AMS-side colour reaches CSS `fill=` / `backgroundColor` as a translucent value instead of a solid one; opaque colours still emit `#RRGGBB` and `normalizeColorForCompare` (which DOES strip alpha) is unchanged so type/colour matching for auto-mapping is unaffected. (c) `backend/app/api/routes/printers.py::get_available_filaments` no longer truncates `tray_color` to 6 chars before emitting it on `/printers/available-filaments` — both the AMS and `vt_tray` branches now pass the full `#RRGGBBAA` through; the dedup key still uses the 6-char RGB so two slots that share an RGB but differ only in alpha still merge into one filament requirement. (d) `frontend/src/components/spool-form/constants.ts` gained a `{ name: 'Clear', hex: '00000000' }` entry to `QUICK_COLORS` — the only 8-char preset, because the native `` can't pick alpha and a dedicated swatch is the only UX that lets the user actually choose transparency. (e) `frontend/src/components/spool-form/ColorSection.tsx` reworked the hex draft contract: previously the hex input was hardcoded to 6 chars and every commit path unconditionally appended `'FF'`, so even pasting `00000000` got truncated to `000000FF` (solid black). Now: the draft accepts up to 8 hex chars; a 6-char commit appends `FF`, an 8-char commit passes through verbatim; on blur a 7-char draft (RGB + one alpha nibble) right-pads the nibble to `0` instead of jumping back to 6-char-pad-RGB; the `selectColor()` helper that the preset swatches call only appends `FF` when the preset is 6 chars, so the new `Clear` swatch lands as `00000000` in `formData.rgba` instead of `00000000FF`. `currentRgba` is canonicalised to 8 chars uppercase and `isSelected()` matches on the full rgba so `Clear` (`00000000`) doesn't collide with `Black` (`000000FF`) in the swatch highlight. (f) Two new shared helpers in `frontend/src/utils/colors.ts` — `getSwatchStyle(rgba)` returns a `{ backgroundColor }` for opaque colours and a `{ backgroundImage, backgroundSize }` 8px checkerboard for alpha=00 (use for div / button backgrounds); `spoolColorString(rgba)` returns a hex string that preserves the alpha byte when alpha < FF (use for SVG `fill=` props and other single-string colour contexts where the consumer can interpret 8-char hex natively). Applied to every simple-swatch site that previously did `style={{ backgroundColor: '#' + rgba.slice(0, 6) }}` or passed a 6-char fill to an SVG icon — those sites would have rendered Clear spools as solid black after the cream rewrite was removed: the three preset rows in `ColorSection.tsx` (recent / catalog / fallback), the spool checkbox swatch in `LabelTemplatePickerModal.tsx`, the per-card colour dot + the SVG `SpoolCircle` in `SpoolBuddyInventoryPage.tsx`, the assigned-spool indicators in `SpoolBuddyAmsPage.tsx` (both internal and Spoolman branches), the four selected-spool summary swatches + the simple-view's spool dot in `SpoolBuddyWriteTagPage.tsx`, the lead-spool indicator in `ForecastPanel.tsx`, the header swatch in `AssignToAmsModal.tsx`, the two spool-list dots in `AssignSpoolModal.tsx` (internal + Spoolman columns), and the `SpoolIcon` fed by `InventorySpoolInfoCard.tsx` / `TagDetectedModal.tsx` / `SpoolInfoCard.tsx` / `LinkSpoolModal.tsx` (which now pass the full 8-char rgba — SVG `fill=` interprets translucent values correctly). `FilamentSwatch.tsx`'s tooltip title fallback also widened so the on-hover hex code shows `#00000000` for a Clear spool instead of misreporting it as `#000000`. **What is intentionally NOT changed**: the native `` value in `SpoolBuddyWriteTagPage.tsx`'s simple-view picker keeps its 6-char hex — that input element doesn't support alpha, and its onChange handler still sets rgba back to opaque `FF` (which is correct behaviour: the user explicitly picked a colour via the picker, not transparency). The colour-sort comparator in `LabelTemplatePickerModal.tsx::colorSortKey` keeps its 6-char alpha-strip — transparent spools sort into the same bucket as black/neutrals which is the right behaviour for ordering. The label-renderer in `backend/app/services/label_renderer.py` keeps its 6-char alpha-strip in `_hex_code_label` because the printed text on a physical label can't show transparency — `_color_from_hex` does honour the alpha byte for the printed swatch fill (alpha=00 → invisible swatch on the label, which is the honest physical answer). The Spoolman auto-sync's `_find_or_create_filament` in `backend/app/services/spoolman.py` still strips alpha when looking up the Spoolman catalog because Spoolman's filament catalog schema only supports 6-char `color_hex` — a transparent AMS spool synced into Spoolman will now match against a `000000` (Black) Bambu Lab filament entry instead of the pre-fix synthetic "PLA Basic" cream entry (RGB `F5E6D3`); both are inaccurate, the post-fix behaviour is at least honest about which colour the catalog has chosen rather than silently inventing a cream spool — users on the Spoolman backend can manually correct the filament assignment if desired. (g) **Removed the cream rewrite** at `backend/app/services/spoolman.py::parse_ams_tray` that silently replaced AMS-reported `00000000` with `F5E6D3FF` ("Light cream/natural color"). That rewrite was a workaround from when the swatch renderer couldn't show alpha — `filamentSwatchHelpers.ts::buildFilamentBackground` already paints a checkerboard underlay for alpha < FF (added in #1154), so the rewrite has been hidden technical debt that made every AMS-detected transparent spool land in inventory as cream instead of clear, with no signal to the user that a colour was substituted. AMS-synced spools now keep their true `00000000` value; the swatch renders the checkerboard; `getColorName` resolves to "Clear". (h) `backend/app/services/spool_tag_matcher.py::create_spool_from_tray` short-circuits the colour-catalog lookup when `rgba` is alpha=00 and stores `color_name="Clear"` directly — without this, a Bambu-RFID transparent spool would resolve against the `#000000` row in the catalog (or `Black` via the HSL fallback) before the frontend's name resolver ever sees it, defeating the alpha-aware fix in `colors.ts`. **Tests** — `src/__tests__/utils/colors.test.ts`: 5 new assertions covering alpha=00 → "Clear" for `hexToColorName`, `getColorName` (including precedence over a catalog entry on the same RGB), and `resolveSpoolColorName`; one existing assertion changed from `12345600` (which now correctly resolves to "Clear") to `123456FF` to keep its intent of "unknown opaque colour returns null". `src/__tests__/components/spool-form/ColorSectionHexInput.test.tsx`: header docblock rewritten to reflect the new 0–8 char draft contract; the "truncates 7–8 char pastes to RGB" test replaced with two new tests — `'0011223344'` paste now truncates to the leading 8 chars (`00112233`) and commits verbatim with no `FF` append, and a 7-char draft on blur pads to 8 with a trailing `0` instead of jumping back to RGB. 17 colours tests, 9 hex-input tests, 53 useFilamentMapping tests, 14 FilamentOverride tests, 10 FilamentSlotCircle tests, 6 `/printers/available-filaments` integration tests, 50 Spoolman API integration tests all green. Backend ruff clean; frontend build clean; i18n parity clean at 4998 leaves per locale. **What this does NOT change**: Spoolman-mode parity is preserved — Spoolman's own picker already supported 8-digit hex and `inventory.py:119` / `spoolman.py:887-889` already passed `00000000` through verbatim (the 6→8 char `FF` pad only fires when `len == 6`), so no parallel mutation is needed on the Spoolman-mode write path. Existing inventory rows that were *already* rewritten to `F5E6D3FF` stay as cream until the next AMS sync overwrites them — a one-time edit is the only path to recover them, and dropping the rewrite means future AMS syncs land the true value. - **Virtual-printer MQTT no longer drops idle slicer connections at exactly 60 s (#1548, reported by @hollajandro)** — Reporter pointed OrcaSlicer at a Bambuddy virtual printer and got a clean MQTT/TLS connect, successful auth, and a normal pushall/get_version exchange — then the slicer dropped exactly ~60 s later, every time, even after a fresh trust of the VP CA, a logged-out Bambu account, and toggling VP mode. Trace from his support bundle: 5 consecutive connect→disconnect cycles all exactly 60 s apart, with no intervening client packets after the initial exchange. Root cause: `backend/app/services/virtual_printer/mqtt_server.py::_handle_client` used a **hardcoded `timeout=60` on every per-packet read**, and `_handle_connect` two functions below explicitly skipped the keepalive field from the CONNECT payload (`# Skip keepalive` / `idx += 2`). So no matter what the client negotiated, the VP server would close the socket after 60 s of silence — and OrcaSlicer's normal pattern after the initial exchange is to sit quietly waiting for the printer to push status updates, which a virtual printer with no real state changes doesn't do. The real Bambu firmware honours the client's keepalive (MQTT spec §3.1.2.10 / §4.4: server must allow 1.5× the negotiated value before disconnecting), which is why Orca works against a real P1S but failed at exactly 60 s against the VP. **Fix**: `_handle_connect` now parses the 2-byte big-endian keepalive value from the CONNECT payload and returns it alongside the auth bool (`tuple[bool, int]`). `_handle_client` uses that to set its per-packet read timeout to `1.5 × keep_alive` after a successful CONNECT, or `None` (no timeout) when the client opted out with `keep_alive == 0` per spec. The 60 s default is retained for the *initial* read before CONNECT arrives, so a TCP-connect-but-never-send still gets reaped. **Tests**: 7 in `test_vp_mqtt_server.py` — `TestHandleConnectKeepalive` (4: returns negotiated value on success, returns 0 for opt-out, returns `(False, 0)` on auth fail / parse error so the caller's tuple-unpack never crashes), `TestHandleClientHonoursKeepalive` (3: idle client with `keep_alive=180` is still alive past the old 60 s boundary; `keep_alive=2` closes idle in ~3 s; a PINGREQ inside the window resets the timeout and the connection exits via DISCONNECT instead of timeout). The integration-style tests feed a synthetic CONNECT into a real `asyncio.StreamReader` and drive the handler on an event loop, so the timeout math is exercised end-to-end, not just unit-mocked. Backend ruff clean. - **A1 no longer auto-replays the previous print after a power cycle when the library row's filename has a doubled `.gcode.3mf` (#1542, reported by @vixussrl-ui)** — Reporter has seven A1s powered through Tuya smart plugs + Home Assistant. After every plug-driven auto-off, turning the printer back on would sometimes start the previous print on its own. Trace from his support bundle: the library row in his DB had `archive.filename = "Cube (1).gcode.3mf.gcode.3mf"` — the `.gcode.3mf` suffix had been appended twice somewhere during the file's import. The dispatcher's `archive.filename` → SD-card-name derivation only stripped ONE trailing `.gcode.3mf`, so the upload landed at `/Cube_(1).gcode.3mf.3mf`. The print ran fine, but the post-print SD cleanup in `main.py` derived its delete target from `subtask_name + ext` (`/Cube_(1).3mf`, `/Cube_(1).gcode`) — neither matched the actually-uploaded path, both 550'd three times, and the real file lingered on the SD card. On next power-up the A1 firmware picked up the leftover .3mf at the SD root and started printing it, exactly like the P1S behaviour the original Issue #374 cleanup was meant to prevent. **Two structural fixes, both shipped together** (no follow-ups per [[feedback_no_followups]]): (1) **shared name derivation**. New `derive_remote_filename(filename)` helper in `backend/app/utils/filename.py` iteratively strips trailing `.gcode.3mf` / `.3mf` suffixes until the bare stem remains, then appends a single `.3mf` and underscore-replaces spaces (the firmware parses `ftp://{filename}` as a URL, spaces break it). Iterative strip handles the doubled-suffix data; the previous single-iteration strip silently fell through to "append .3mf to whatever's left", which is how doubled extensions ended up on the SD card in the first place. The helper is the single source of truth for the SD-card target name — three previously-duplicated upload sites now route through it: `_run_reprint_archive` and `_run_print_library_file` in `backend/app/services/background_dispatch.py`, and the queue dispatch in `backend/app/services/print_scheduler.py`. (2) **cleanup uses the same algorithm as upload**. The post-print SD cleanup in `main.py` now fetches `archive.filename` when `archive_id` is resolved and tries `derive_remote_filename(archive.filename)` FIRST, with the legacy `/{subtask_name}.3mf` and `/{subtask_name}.gcode` paths kept as fallbacks for archive-less prints (subtask never matched any archive) and for older naming variants. De-duped when the primary target equals one of the fallbacks, so the happy-path delete count is unchanged. On the reporter's case the new primary candidate is `/Cube_(1).gcode.3mf.3mf`, matching the on-card file and deleting it cleanly — no more ghost print. **Out of scope** (separate concern): the upstream import path that produced the doubled `.gcode.3mf.gcode.3mf` filename is not addressed here — the iterative strip in `derive_remote_filename` defends against it everywhere it matters (upload target, cleanup target), so any future user with the same legacy data still gets clean dispatch and cleanup. **Defensive hardening caught in the first integration run**: the initial helper had no input type check, just a `while True` strip loop with `endswith` / slice. When a unit test mock (`unittest.mock.MagicMock`) was passed in by accident via the new cleanup path, `mock.endswith(".gcode.3mf")` returned a truthy `MagicMock` on every iteration and the slice `stem[:-10]` returned another `MagicMock` — the loop never reached the `else: break` branch. Each iteration allocated a fresh `MagicMock` until the LXC cgroup OOM-killer reaped the pytest worker at 61 GB anon-rss (visible in `journalctl -k` as `oom_memcg=/lxc/109` with `CONSTRAINT_MEMCG`). Fixed by adding an `isinstance(filename, str)` guard that raises `TypeError` instead of entering the loop — turns the silent infinite allocation into a loud, debuggable error. The same guard protects production: if a corrupt DB row or ORM edge case ever surfaces a non-str `archive.filename`, the cleanup logs a warning via its outer `try/except` instead of OOMing the backend. **Tests**: 10 in `TestDeriveRemoteFilename` in `test_filename_validation.py` (single `.gcode.3mf` strip, single `.3mf` strip, bare stem appends `.3mf`, space→underscore, the literal `Cube (1).gcode.3mf.gcode.3mf` reproducer from #1542 → `Cube_(1).3mf`, doubled `.3mf.3mf`, mixed `.gcode.3mf.3mf`, raw `.gcode` preserved as `.gcode.3mf` since `.gcode` alone is a valid sliced file, idempotence — running the helper on its own output is a no-op, Unicode stem preserved, **type guard** — `MagicMock` / `None` / `int` inputs all raise `TypeError` with a clear message instead of entering the loop). 315 dispatch + print-complete-path tests green (`test_phantom_print_hardening.py`, `test_print_start_assigns_printer_id_to_vp_archive.py`, `test_print_start_expected_promotion.py`, `test_cost_tracking.py`, `test_print_queue_api.py`'s `TestAbortedStatusNormalisation` — which was the suite that originally OOM'd, now passes in 2 s serial / 12 s under `-n 30`). Backend ruff clean. - **Print filenames with FAT32-illegal characters now rejected at rename/upload/queue time instead of failing at FTP (#1540, reported by @anthonyma94)** — Reporter could rename a library file to `L|R.3mf`, and the PUT `/library/files/{id}` endpoint accepted it because `library.py:4011` only blocked `/` and `\`. The pipe (and the rest of the FAT32/exFAT-illegal set `< > : " / \ | ? *`, control chars, trailing dots/spaces) flowed through to FTP upload time, where the printer's SD card rejected the create with `553 Could not create file` — far from the rename action that caused it. Bambu Studio refuses these names client-side in its save dialog; Bambuddy now does the same. **Fix**: new `backend/app/utils/filename.py` exporting `validate_print_filename(name)` and `InvalidFilenameError` — single source of truth for the rejected set (Bambu-Studio-parity: the nine chars above, control codes 0x00-0x1F, empty/whitespace-only, bare `.`/`..`, trailing space or dot, and 255 UTF-8 bytes max). Wired into three boundaries: (a) `update_file` at `library.py` replaces the path-separator-only check; (b) `upload_file` at `library.py` rejects bad multipart-upload filenames before they're persisted; (c) `print_library_file` adds a pre-flight check so older library rows that pre-date the rename validation fail with an actionable 400 instead of an obscure FTP 553; (d) `add_to_queue` at `print_queue.py` same pre-flight so queued files don't sit waiting just to fail at dispatch. The print/queue checks deliberately refuse rather than auto-rename — silently rewriting user filenames was the wrong UX (Studio doesn't, and the user explicitly chose that name). Existing rows with illegal names are left alone; users see a clear error pointing at rename. **Frontend**: the rename modal in `FileManagerPage.tsx` now mirrors the same character set client-side, shows the offending char inline as a red error below the input, and disables the Rename button while invalid — matches Bambu Studio's instant feedback rather than a round-trip-to-400. **i18n**: new `fileManager.invalidFilenameChar` key with real translations across all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW + en) per [[feedback_translate_dont_fallback]]; parity script clean at 4998 leaves per locale. **Tests**: 26 in `test_filename_validation.py` (parameterised over every char in `INVALID_FILENAME_CHARS`, the exact `L|R.3mf` reproducer from the bug, empty/whitespace/`.`/`..`, control chars, trailing space/dot, byte-length cap with multi-byte UTF-8 to verify it's bytes not codepoints). Backend ruff clean; frontend build clean. - **Fallback archives now carry MQTT-derived filament type + colour when the 3MF can't be downloaded (#1533, reported by @JmanB52D)** — Reporter (lead of a maker-space 3D Fab area) was evaluating Bambuddy partly to count filaments per print for AMS expansion planning; print log was showing "—" in the filament column for every job. Trace: a P2S in VP proxy mode where the slicer's .3mf upload lands on the real printer's SD card, then the printer locks the file mid-print and refuses every FTP read (the existing fallback-archive code path in `main.py:2596`, originally added for P1S/A1 printers, anticipates this: *"FTP has file size limitations"* — same effective behaviour on P2S). The user log shows ~12 FTP candidate paths attempted on every print start, every one returning 550, then directory listings on `/cache /model /data /data/Metadata` also returning 550, then the fallback archive being created with `file_path=""` and **every filament column NULL** — even though the MQTT print-start payload already had the AMS state and the slicer's slot-per-print-filament mapping sitting in `data["ams"]["ams"]` / `data["ams_mapping"]`. **Fix**: new `_extract_filament_data_from_mqtt(data, ams_mapping)` helper in `backend/app/main.py` (placed next to the existing `_get_start_ams_mapping`) walks `data["ams"]["ams"][*].tray[*]` to build a global-tray-id → (tray_type, tray_color) map, then narrows to slots referenced by `ams_mapping` if present (slicer order preserved; -1 entries for VT-tray skipped), or falls back to every loaded slot otherwise. Output is a comma-separated `filament_type` + `filament_color` in the same shape the 3MF extractor produces — so the inventory page, Quick Stats filament rollup, and `len(filament_type.split(','))` per-print count all light up identically for fallback rows. Truncated to the model's column limits (50 / 200). Defensive against malformed MQTT shapes (non-dict entries, non-int ids, missing fields) since this runs in the print-start hot path and a raise would break print logging entirely. The fallback `PrintArchive(...)` constructor now passes `filament_type=` / `filament_color=` from the helper. **What this is NOT**: not per-filament gram usage (that needs the 3MF's `slice_info.config` or a deep AMS layer-delta integration via `usage_tracker`) — only types and colours. The user explicitly asked for "the number of filaments used to know if or when we need to expand AMS units", which is exactly what this gives them (`SELECT COUNT(DISTINCT split(filament_type, ',')) ...` or the existing inventory count surfaces). A separate, larger piece of work to capture the .3mf in VP proxy mode at upload time (by sniffing FTP STOR in `tcp_proxy.py`) is the real long-term fix for any user who wants full 3MF-derived archive metadata in proxy mode; it's not bundled here. **Tests**: 15 in `test_fallback_archive_mqtt_filament.py` (`backend/tests/unit/`) covering: empty / malformed / no-loaded-slot payloads return `{}`; the no-mapping path lists every loaded slot in ascending global-id order with colours uppercased; an `ams_mapping` filters to and reorders by the slicer's order; VT-tray sentinels (`-1`) are filtered; dual-AMS layouts resolve `unit*4 + tray` correctly across units; a mapping pointing at unknown slots falls through to the known subset, but an entirely-unknown mapping returns `{}` rather than misreporting from the all-slots fallback; both column-limit truncations enforced; missing-colour-but-present-type emits `filament_type` only; defensive against non-dict/non-int garbage in the AMS list without raising. Existing 22 print-start unit tests untouched and green. Backend ruff clean. - **SpoolBuddy: Tare status banner no longer sits at "Waiting for device..." forever (#1536, reported by @flom89)** — On the SpoolBuddy kiosk's Settings → Scale (Waage) tab, pressing TARE wrote the "Tare command sent. Waiting for device..." banner but had no mechanism to resolve it. The daemon writes back through `POST /spoolbuddy/devices/{id}/calibration/set-tare` (which stamps `tare_offset` + `last_calibrated_at` on the device row), the device list query already polls every 10 s, but `handleTare` in `frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx` was set-and-forget — the banner persisted indefinitely. The "Calibration complete!" banner on the full calibration flow had the same shape and stayed forever too. **Fix**: a completion watcher that snapshots `device.last_calibrated_at` when TARE is pressed, sets an `awaitingTareSince` state, invalidates the device-list query every 1 s while that state is active (so detection responds within ~1 s instead of waiting on the 10 s background poll), and when `last_calibrated_at` advances past the snapshot flips the banner to "Tare complete!" with a 3 s auto-dismiss timer. A 15 s timeout on the watcher fails open to "Tare timed out — is the SpoolBuddy daemon running?" so a dead daemon doesn't leave the user staring at the spinner. The Calibration-complete success banner and the calibration-failed error banner now share the same auto-dismiss helper (3 s success, 5 s error). All timers are owned by a `useRef` that cleans up on unmount; pressing TARE while a previous dismiss is queued cancels the old timer. **i18n**: two new keys (`spoolbuddy.settings.tareComplete`, `spoolbuddy.settings.tareTimedOut`) translated into all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW + en) per [[feedback_translate_dont_fallback]] — no English fallbacks. Parity script passes at 4997 keys × 9 locales. Frontend build clean. - **ntfy notifications: honest User-Agent + actionable error when the server is behind a Cloudflare challenge (#1534, reported by @apizz)** — Reporter pointed an ntfy server behind a Cloudflare Tunnel at Bambuddy and got `HTTP 403: ...Just a moment...` on every Test click. They reproduced the same response with a plain `curl -H "Authorization: Bearer " -d "test" https://ntfy.example/` — confirming the 403 originates from Cloudflare's JS challenge intercept (Bot Fight Mode / "Under Attack" mode), not from Bambuddy or ntfy. Cloudflare returns its interstitial HTML to any non-browser client at the edge, so the request never reaches the user's ntfy backend at all. Bambuddy can't solve a JS challenge from a backend — the only real fix is on the user's Cloudflare side (a security-skip rule for the hostname/path, disabling Bot Fight Mode for that hostname, or fronting the server with Cloudflare Access using a service token). Two improvements shipped to make this footgun self-diagnosable for the next user who hits it. **(1) Honest User-Agent on the notification HTTP client.** `backend/app/services/notification_service.py` was the one outbound httpx client in the codebase that didn't set the project-standard `Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)` UA — it leaked `python-httpx/` instead. Brings it in line with `bambu_cloud` / `makerworld` / `firmware_check` / `inventory` (all unified during the May 2026 compliance pass) and makes Bambuddy a more obvious citizen to upstream WAFs and proxy operators. Won't defeat Cloudflare's JS challenge (the user's curl test proves CF blocks regardless of UA) but it's a consistency / hygiene fix with no regression risk. **(2) Cloudflare-challenge detection on the ntfy error path.** New `_looks_like_cloudflare_challenge(response)` helper checks the response shape (`Server: cloudflare` or `cf-mitigated` header, or `...Just a moment...` body). When a 403/non-success response matches, the error returned to the UI now reads: *"HTTP 403 — ntfy server is behind a Cloudflare challenge. Bambuddy was served the JS challenge page instead of reaching ntfy. Cloudflare cannot be solved from a backend; add a Cloudflare security-skip rule for this hostname, disable Bot Fight Mode, or front the server with Cloudflare Access using a service token. (#1534)"* — actionable, points at the real fix, removes the raw HTML dump. A regular 403 (e.g. ntfy auth failure with a plain `forbidden: invalid auth token` body) still surfaces the original body so genuine auth errors stay debuggable; the interceptor only fires on the Cloudflare shape. **Tests**: 3 new in `TestNtfyOutbound` in `test_notification_service.py` — (a) the lazy-constructed httpx client carries the honest UA header on first use; (b) a 403 with `Server: cloudflare` + `Just a moment...` body produces the actionable error and does not echo `.3mf`. The file was physically written there (a path outside the user's mounted data volume — orphaned on container restart) and only the *final* `source_path.relative_to(settings.base_dir)` raised, so every retry left another orphan. Affected reporter is on a QNAP Docker host with the standard `/app/data` mount; both maintainer and triage initially diagnosed it as a Docker volume misconfiguration, but the traceback shows the bug is purely on Bambuddy's side — the user's setup was correct. **Fix**: new private helper `_resolve_source_3mf_path(archive, source_filename)` in `backend/app/api/routes/archives.py` centralises the destination computation. Normal archives still nest the source under `/source/`. Fallback archives (empty `file_path`) now land under `/archive/no_source//` instead — a deterministic, addressable location that stays inside the data volume, and the existing read sites (`download_source_3mf`, `download_source_3mf_by_filename`, the slicer-token routes, `delete_source_3mf`) all continue to work because they read back via `settings.base_dir / archive.source_3mf_path`. The helper also defensively asserts the resolved directory is inside `base_dir.resolve()` regardless of where it came from, so a row corrupted by an old import or a manual SQL edit fails with a clear 500 message ("Archive N resolves to a path outside the data directory; cannot attach source.") instead of silently writing outside the volume. Both upload sites (`upload_source_3mf` and `upload_source_3mf_by_name`, the slicer-post-processing endpoint) now route through the helper, so neither can independently drift back into the bug. **Tests**: 2 new in `TestUploadSourceThreeMF` in `backend/tests/integration/test_archives_api.py` — (a) `test_fallback_archive_source_upload_lands_under_base_dir` creates an archive with `file_path=""`, uploads a minimal valid 3MF, asserts 200 status, that the returned `source_3mf_path` is relative (not `/app/source/...`), that the file physically exists under the patched `base_dir`, and that the path is the deterministic fallback location keyed off `archive.id`; (b) `test_normal_archive_source_upload_unchanged` is the same flow against an archive with a populated `file_path`, asserting the existing `archives/test/source/.3mf` layout is preserved (regression guard against the helper accidentally changing the normal path). 57/57 in `test_archives_api.py` green under `pytest -n 30`. Backend ruff clean. **Note**: existing orphan files at `/app/source/.3mf` from prior failed retries inside an affected user's container can be safely deleted; they were never indexed in the DB, never reachable from the UI, and would have vanished on the next container restart anyway. - **SpoolBuddy weight sync no longer silently lands on a stale local row when Spoolman is enabled (#1530, reported by @chesterakl)** — Reporter (Spoolman mode, H2C, internal "manually add then NFC-link" flow) saw the SpoolBuddy "Sync Weight" button flip to "Synced!" but the Spoolman-backed inventory listing never updated. Cause: `POST /spoolbuddy/scale/update-spool-weight` (`backend/app/api/routes/spoolbuddy.py`) ran the lookup local-DB-first and only fell through to Spoolman on local miss — but the upstream `nfc/tag-scanned` route is exclusive (always-Spoolman when `spoolman_enabled=true`, after the #1119 / nfc-routing fix). When the user's local DB still held a stale `Spool` row that happened to share a numeric id with the Spoolman spool the NFC tag mapped to, the sync endpoint absorbed the update into the stale local row, returned 200 with the local `weight_used`, and the actual Spoolman spool went untouched. The support log confirms it: 17 sync attempts across two days, every line logged `SpoolBuddy updated spool 2 weight: …g on scale, …g used` (the local-branch log format) and the `SpoolBuddy updated Spoolman spool …` line (which only fires in the Spoolman branch) never appeared. The bug couldn't be reproduced on developer setups because they don't carry a leftover local row with a colliding id. **Fix**: `update_spool_weight` now routes exactly like `nfc_tag_scanned` — `_get_spoolman_client_or_none(db)` first, and that result picks the branch exclusively. Spoolman mode goes straight to Spoolman with no local-DB read; local mode does the local update and returns 404 (not "fallback to Spoolman") on a local miss. Matches [[feedback_inventory_modes_parity]] — the two inventory modes must behave identically from the user's perspective, including which row gets written. The docstring now spells out the routing contract so the next reader doesn't reintroduce the local-first read. **Tests**: 1 new regression test in `TestUpdateSpoolWeightSpoolman.test_stale_local_row_does_not_shadow_spoolman` — creates a local `Spool` with the same numeric id as a mocked Spoolman spool, posts the sync, asserts (a) Spoolman's `update_spool` was called with the correct remaining weight, and (b) the local row's `weight_used` and `last_scale_weight` are unchanged after a `refresh()` against the live DB. The existing 8 tests in that class continue to assert the Spoolman branch math (filament/spool-level tare priority, 404 / 503 mappings, 250g fallback warning). 9/9 green; 126/126 across the spoolbuddy + spoolman-filament-patch integration suites green under `pytest -n 30`. **Cleanup hint for affected users**: anyone in Spoolman mode with leftover local Spool rows from before they switched should delete those rows — they're inert under the new routing, but they were eating sync attempts under the old. Backend ruff clean. - **Paused prints no longer inflate maintenance hours (#1521, reported by @TempleClause)** — The `track_printer_runtime` background task in `backend/app/main.py` counted both `RUNNING` and `PAUSE` states equally toward `runtime_seconds`, which feeds every hours-based maintenance interval (lubricate rods, clean nozzle, check belts, etc.). Maintenance items measure *mechanical wear*, and pause time involves no motion — so a print paused overnight stretched the maintenance clock forward by ~8 h without any actual wear, triggering "lubricate rods" warnings earlier than warranted. Reporter found this by code review (no support bundle), flagged it cleanly with the exact line in `main.py` and three ranked solution options. **Fix**: option 1 (exclude PAUSE entirely) — `state.state in ("RUNNING", "PAUSE")` → `state.state == "RUNNING"`. PAUSE now follows the same path as FINISH / IDLE / PREPARE: the elapsed-time accumulator skips it, and `last_runtime_update` is cleared so a later RUNNING transition starts fresh and doesn't back-bill the pause. No setting / toggle (reporter's option 3 was deliberately the throwaway — this is a wear-tracking semantic, not a user preference); no cap (option 2) — wear during pause is zero, not "reduced". Docstring and field-comment trail updated across `main.py`, `models/printer.py:23`, and the two `api/routes/maintenance.py` route docstrings that all previously described the field as covering "RUNNING and PAUSE states". **Out of scope**: retroactive backfill of existing `runtime_seconds` values — already-accumulated pause time cannot be split out, only future accumulation is fixed. Users with hours-based maintenance intervals already set will see slower accumulation going forward (the correct outcome), so a previously-near-due item may take longer to ring than under the old behaviour. **Tests**: 3 new in `test_runtime_tracking_pause.py` pinning the new contract — PAUSE does NOT accumulate and clears `last_runtime_update`; RUNNING still accumulates and updates the timestamp; a non-active state (FINISH) clears `last_runtime_update` to prevent back-billing the idle time when the printer next goes RUNNING. The tests drive the actual `track_printer_runtime()` coroutine through a single iteration via patched `asyncio.sleep` against an in-memory SQLite DB, so they catch any regression in the predicate at the call site (not just an extracted helper). Backend ruff clean; targeted 24-test rod/runtime subset all green. - **Quick Stats: user-cancelled prints now have their own bucket and no longer drag down the Success Rate gauge (#1390 follow-up, reported by @IndividualGhost1905)** — Reporter saw `Total prints: 20 / Success: 18 / Failed: 1` and asked where the 20th print went; the breakdown only showed Successful + Failed, so a cancelled run silently inflated the total without appearing anywhere. The earlier #1390 round had committed a test that *locked in* the bug — `it('uses total_prints as denominator so cancelled/stopped events count')` asserted the gauge should divide by `total_prints`, which lumped user/queue-cancelled jobs in with quality outcomes and conflated user intent with printer performance. **Cause**: `PrintLogEntry.status` has six values in production (`completed`, `failed`, `aborted`, `stopped`, `cancelled`, `skipped`) but the Quick Stats endpoint in `api/routes/archives.py` only counted two — `completed` → Successful, `status == "failed"` → Failed — and used a raw `count(*)` for Total Prints, so the other four statuses ended up in Total without surfacing in any breakdown row. `aborted` was particularly silent: classified as a failure elsewhere in the codebase (`failure_analysis.py`, `main.py:430,1729`) but not counted toward `failed_prints` in stats. **Fix**: three-bucket classification across the whole stats surface, matching how the rest of the codebase already groups these statuses. Quick Stats now returns `successful_prints` (completed), `failed_prints` (failed + aborted — printer-detected quality failures), and a new `cancelled_prints` (stopped + cancelled + skipped — user/queue interruptions). The SuccessRateWidget gauge divides by `successful + failed` only, so cancelling a roll because you changed your mind doesn't ding the printer's success rate — a Cancelled row in the breakdown surfaces the count so it doesn't silently vanish from Total Prints. The Failure Analysis service applies the same denominator change (`failure_rate = failed / (successful + failed)`) to both the headline rate and the per-week trend, so a week with no failures but several cancellations no longer reads as a misleading 0/N. **Schema change is additive-safe**: `ArchiveStats.cancelled_prints` defaults to `0` so any historical fixture validating against the model still parses; the frontend type also defaults the display to `0` when the field is missing. **i18n**: new `stats.cancelled` key with real translations across all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW) per [[feedback_translate_dont_fallback]]; parity script clean at 4994 leaves per locale. **Tests**: existing `it('uses total_prints as denominator …')` test inverted to assert the new behaviour (40 completed / 20 failed / 35 cancelled → gauge shows 67%, Cancelled row reads 35), `cancelled_prints: 0` added to the shared mock so the unchanged-display assertion (140/150 → 93%) still holds since `140 / (140 + 10) = 93.33%` rounds identically. 33 StatsPage tests + 6 backend stats/failure tests green; frontend build + backend ruff clean. **Follow-up (cosmetic):** the new Cancelled row's Ban icon rendered in `text-bambu-gray` while the Successful and Failed icons used semantic `text-status-ok` / `text-status-error` tokens — reporter (@IndividualGhost1905) noted the asymmetry and asked for an orange to match what Archives + notification badges use for cancelled. Switched the Cancelled row to `text-status-warning` (amber-500, same token family as the other two rows), so all three icons are now semantic-token-driven and the new row matches the colour the user already associates with cancelled status elsewhere in the UI. - **VP queue mode no longer blocks BambuStudio Send while the target printer is mid-print (#1558, reported by @phieb)** — Reporter set up a non-proxy queue-mode VP with a target printer bound, started a print on the real printer, then tried Send to the VP from BambuStudio — slicer refused with the "busy" pre-flight error even though Bambuddy's whole job is to look idle so jobs queue any time. Cause traced by reporter: `SimpleMQTTServer._send_status_report` forces `gcode_state=IDLE` and storage indicators on top of the cached-as-base mirror — good — but the cached branch overrode only a handful of fields, and the live print-progress fields from the mirrored real `push_status` (mc_print_stage, mc_percent, mc_remaining_time, stg, stg_cur, layer_num, total_layer_num, print_error) passed through unchanged. The VP emitted a contradictory report (gcode_state=IDLE but mc_percent>0, stg_cur>0, ...) and BambuStudio's Send pre-flight read it as busy. Without a bound target the synthetic-stub branch reported all of these idle and Send worked — isolating the leak to the cached branch. **Fix**: in the cached branch, also override those 8 activity fields to the idle values the synthetic-stub branch uses (`mc_print_stage=""`, `mc_percent=0`, `mc_remaining_time=0`, `stg=[]`, `stg_cur=0`, `layer_num=0`, `total_layer_num=0`, `print_error=0`). Same shape as the #1228 storage-indicator overlay — internally consistent with the forced IDLE state while AMS / version / temperatures keep mirroring. **Behavioural caveat for users**: a slicer connected to the VP just for monitoring no longer sees the real printer's mid-print progress through the VP (since the cached push now reports idle). The real printer's IP / UI remains the source of truth for progress. Per the issue intent, this trade-off is explicit. **Tests**: new `test_live_progress_fields_zeroed_in_cached_branch` in `test_vp_mqtt_bridge.py::TestStatusReportCachedAsBase`. - **VP `_pending_files` / temp-file leak on every error path across the three file handlers** — Pre-fix: `_archive_file`, `_queue_file`, and `_add_to_print_queue` only popped `_pending_files` and unlinked the temp file on the success branch. When archival failed (DB outage, ArchiveService raise, queue insert error), the entry stayed in the dict — and since the FTP layer keys its "same-name STOR already in flight" guard on filename, the slicer's next retry was spuriously rejected; the upload_dir also accumulated orphan temp files indefinitely. Each handler now uses a `try / finally` that pops the marker and unlinks the temp file regardless of whether the body succeeded. 3 unit tests in `test_virtual_printer.py::TestVirtualPrinterInstance` (one per handler) inject a failure mid-flight and assert both invariants. - **VP queue position now picks `MAX(position)+1` instead of hardcoded `1`** — Pre-fix: VP-uploaded queue items always landed at `position=1`. With non-empty queues this created duplicate position=1 rows; the scheduler orders by `(printer_id, position)` so ties resolved in undefined DB-internal order, and repeat VP uploads accumulated multiple position=1 rows — making the queue's visible ordering non-deterministic and dispatching out of the user's intended sequence. Now the VP path runs the same `SELECT MAX(position) FROM print_queue_items WHERE printer_id= AND status='pending'` query the canonical `POST /print-queue/` route uses and inserts at `max_pos + 1`. Defensive `try/except` around the `.scalar()` call so a mocked DB in tests can't cause a `TypeError` from MagicMock arithmetic. 1 unit test pins the MAX+1 behaviour (with `MAX=7` the inserted item lands at `position=8`). - **VP DELETE route cleans orphan `PendingUpload` rows + on-disk upload_dir** — Pre-fix: `DELETE /virtual-printers/{vp_id}` stopped the running instance and removed the row, but the `base_dir/uploads//` directory and any `PendingUpload` rows that referenced it lingered. The user only learned the rows were orphaned by trying to archive one and getting a "file missing" → flip-to-discarded auto-handler — not exactly a clear signal. Now the DELETE handler queries `PendingUpload` rows whose `file_path` starts with the VP's upload_dir prefix, marks them `status='discarded'`, then `shutil.rmtree`s the directory after the DB commit succeeds (so a crash between commit and rmtree leaves orphan files at worst, not orphan rows pointing at a missing tree). 2 unit tests in `test_vp_delete_cleanup.py` cover the cleanup-with-orphans + clean-no-op paths. - **VP `MQTTBridge._refresh_loop` crash no longer leaks the raw_message_handler** — Pre-fix: if any exception escaped `_resolve_client` (the IP-encoding branch was the most likely culprit), `_refresh_loop` caught it with `logger.exception` and returned. The task completed `status=done` — not cancelled, not raising — so `stop()` never ran and `_unbind_client` never fired. `self._on_printer_raw` stayed registered on the live `BambuMQTTClient` and kept reading / writing `self._latest_print_state` on every real-printer message even though the VP bridge was functionally dead, creating a behaviour leak that persisted across VP restart. Now the crash exit explicitly calls `_unbind_client()` so the orphaned handler is detached even when the loop dies abnormally. - **VP `sync_from_db` serialised by `asyncio.Lock` (concurrent-PUT race)** — Two simultaneous `PUT /virtual-printers/{id}` calls (e.g. browser racing the auto-save trigger) could race the inner start/stop sequence and leave duplicate sub-services bound to the same port — split-brain state that only resolved on the next Bambuddy restart. `VirtualPrinterManager.__init__` now holds a `_sync_lock`; `sync_from_db` wraps the body in `async with self._sync_lock`. Single VP updates still complete in well under a second, so the serialisation isn't visibly slower. - **VP `_slicer_print_options` cache bounded at 128 entries with FIFO eviction** — Pre-fix: the dict that stashes the slicer's `project_file` options (so `_add_to_print_queue` can inherit timelapse / bed_leveling / flow_cali / etc.) had no bound. If the slicer sent `project_file` for a filename whose FTP upload was rejected / cancelled / non-3MF, the stash was orphaned and the dict grew one entry per such event for the VP's entire uptime. The new bound triggers eviction of the oldest entry once 128 entries accumulate. - **VP `MQTTBridge` sticky-key carry-forward now uses `copy.deepcopy`** — Pre-fix: a sticky key carried over from the previous cache was assigned by reference, sharing nested dicts/lists between the old and new state. No current code path mutates a carried-forward sticky key in place, so this was latent — but a future merge that did would corrupt both copies. Defensive `copy.deepcopy` on the carry-forward removes the foot-gun without changing observable behaviour. - **VP `MQTTBridge._refresh_loop` and `SimpleMQTTServer._send_status_report` cached-path use deepcopy** — `_send_status_report` cached branch was using `dict(cached)` — a shallow copy. Today's mutations are top-level only, but a future override that wrote into a nested dict (e.g. `online`, `upgrade_state`, `ipcam`) would corrupt the bridge cache and be read by every subsequent subscriber until the next real-printer push landed. Switching to `copy.deepcopy` removes the foot-gun. - **VP `SlicerProxyManager` lifecycle hardening** — Multiple proxy-mode fixes shipped together: (a) `_ftp_data_proxies` and `_actual_ftp_port` are pre-initialised in `__init__` instead of `start()`, so `stop()` called before `start()` finishes (rapid mode-switch race) no longer raises `AttributeError` and leaves sockets stranded; (b) `_actual_ftp_port` now tracks the iptables-redirect target when the deployment uses `REDIRECT --to-port` to let non-root containers serve on 990, and `get_status()` returns it — diagnostic was previously probing the class constant 990 and false-failing on every working redirect deployment; (c) the FTP-data-proxy `auto_close` tasks (101 of them in `FTPTLSProxy`) are now tracked on `_auto_close_tasks` and cancelled in `stop()` — previously they lingered ~60 s holding server references and could fail the next start with "address already in use"; (d) probe servers `await server.wait_closed()` on stop instead of just `srv.close()` — same rapid-restart race. - **VP diagnostic now probes both bind ports 3000 and 3002** — Pre-fix: non-proxy bind diagnostic only probed 3002. The bind server in server mode actually listens on both (plain on 3000, TLS on 3002 per `bind_server.py:BIND_PORTS`); a VP whose plain listener failed to start but TLS listener succeeded would pass the diagnostic while being half-broken. Now `port_bind` reports `pass` only when both probes succeed. New `PORT_BIND_PLAIN = 3000` constant. - **VP FTP `stop()` awaits cancelled sessions instead of `sleep(0.1)`** — A session mid-write, mid-TLS-handshake, or holding a 60 s data-read could easily outlive the 100 ms sleep, and the server's `close()` would run while underlying sockets were still in use. Now `stop()` cancels each session task and `asyncio.gather`s them with `return_exceptions=True`. Stop is a few ms slower in the typical case; worst-case bounded by whatever asyncio takes to propagate cancellation. - **VP child sub-services (FTP / MQTT / Bind / SSDP) expose `ready` event for accurate `is_running`** — See Added section for full description. - **VP per-VP TLS certificate auto-regenerates when the shared CA is rotated** — Pre-fix: `ensure_certificates` only checked that the per-VP cert file existed. When the shared CA was regenerated (its expiry within 30 days), per-VP certs on disk were still signed by the OLD CA — slicers that imported the NEW CA failed handshake. The check is now a real signature verification: `ensure_certificates` loads the on-disk per-VP cert and the on-disk CA, and verifies the cert's signature against the CA's public key via `cryptography.hazmat.primitives.asymmetric.padding.PKCS1v15`. On `InvalidSignature` (rotation detected), the per-VP cert is regenerated under the current CA. **The unit-test driven a real bug** in an earlier version of this fix: comparing Subject DN was insufficient because Bambuddy's auto-generated CAs share the same Subject Name ("Virtual Printer CA"), so DN-match returned True even after rotation. 3 tests in `test_vp_certificate_rotation.py` (reuse-when-issuer-matches, regen-when-rotated, no-CA-returns-False). - **VP `tailscale.py::get_status` now catches `asyncio.TimeoutError`** — Pre-fix: `_run_tailscale` could re-raise `TimeoutError` after killing a stuck subprocess. The `except OSError` clause in `get_status` didn't catch it, so the exception propagated all the way to the FastAPI route handler and crashed the VP management UI for any user whose host `tailscaled` was lagging. Now the except clause covers both `OSError` and `asyncio.TimeoutError`, returning a `TailscaleStatus(available=False, error=...)` either way. - **VP `certificate.py` CA save uses correct parent directory** — Pre-fix: `_get_or_create_ca` created `self.cert_dir` (the per-VP subdirectory) before writing the CA, but the CA writes target `self.ca_key_path.parent` (the shared CA dir — potentially a different path). Latent because the manager pre-creates both directories; surfaced by the path-correctness audit. - **VP `_extract_plate_id` logs failures at debug instead of silent** — Pre-fix: `except Exception: return None` swallowed any failure to parse `Metadata/slice_info.config` without a log. A malformed 3MF then produced a wrong-plate dispatch with no diagnostic trail. The except now logs at debug so support bundles capture the parse error. ## [0.2.4.4] - 2026-05-30 ### Security - **Fail-open authentication bypass on database errors — unauthenticated access to every protected endpoint during a forced DB-exception window ([GHSA-6mf4-q26m-47pv](https://github.com/maziggy/bambuddy/security/advisories/GHSA-6mf4-q26m-47pv), CVSS 9.8 critical, reported by @wondercrash)** — Two functions in the auth path caught every exception and returned the "allow" answer instead of denying the request: `is_auth_enabled` in `backend/app/core/auth.py:473` (returned `False`, treating "DB query raised" as "auth is disabled") and the global `auth_middleware` in `backend/app/main.py:5590` (caught everything and called `await call_next(request)` with a comment that explicitly said "fail open for DB issues"). An attacker who could trigger any exception during the auth probe — the reporter's documented PoC floods `/api/v1/auth/login` until the process exhausts its file-descriptor budget and the next SQLite `connect()` raises — could then hit any protected endpoint during that fail-open window with no token. CWE-636 (Failing Open) / CWE-755 (Improper Handling of Exceptional Conditions). Affected versions `>= 0.1.6`. **Impact during the window**: create a persistent admin account or API key, download the database backup (hashed passwords + encryption keys + printer access codes + MFA secrets), read/modify settings, control printers. **Fix**: `is_auth_enabled` now only returns `False` for the legitimate "settings row absent" case (`scalar_one_or_none()` returns `None` → system was never configured for auth); any actual exception propagates so the caller can deny the request. `auth_middleware` returns `503 Service Unavailable` on any probe failure instead of letting the request through. The principle applied throughout: a failure to verify the auth state means the request is denied, not granted. **Regression tests** in `backend/tests/unit/test_auth_fail_closed.py` pin the four contracts: `is_auth_enabled` propagates DB exceptions, returns `False` for the no-row case, returns `True` for `value=true`, returns `False` for `value=false`. An existing security test (`test_security.py::test_status_returns_500_on_db_error`) was renamed to `test_status_returns_503_on_db_error` and updated to accept either 500 or 503 (both fail-closed) while explicitly verifying the SQLAlchemy detail string doesn't leak in the response body. **Codebase audit**: grepped every `except Exception` in `backend/app/core/auth.py` and `backend/app/core/permissions.py` for the same shape; `_validate_api_key` catches but returns `None` which leads to a 401 downstream (fail-closed), `is_advanced_auth_enabled` in `backend/app/api/routes/auth.py` already propagates correctly, `permissions.py` has no catch-alls — no other auth-decision predicate carries this anti-pattern. ## [0.2.4.3] - 2026-05-24 ### Added - **SliceModal: "Slice all plates" toggle for multi-plate sources** — Re-slicing a multi-plate 3MF (e.g. a "parted statue" project where each plate carries a different body part) required opening the slice modal once per plate, picking the printer / process / filaments every time, and ending up with one archive per plate. The footer now has a "Slice all N plates" checkbox for multi-plate sources: tick it and the "Slice" button flips to "Slice all N plates", submitting `plate=0` instead of the picked plate index. The backend forwards this as the BS CLI's `--slice 0` "all plates" sentinel, which produces a **single output 3MF whose `Metadata/plate_N.gcode` entries cover every plate** — one slice call, one archive, every plate inside. **Filament dropdowns also adapt**: with the toggle on, they show the *union* of every plate's slot usage (a slot a plate-2 part paints with but plate 1 doesn't was previously invisible — the user could only pick filaments for the actively-viewed plate). The union is computed client-side from the existing `platesQuery.data.plates[*].filaments` payload, so no extra round-trip. The backend `SliceRequest.plate` field's range relaxed from `ge=1` to `ge=0` to admit the sentinel (the schema's docstring spells out the three semantics: `None` → default plate 1, `0` → all plates, `>= 1` → that plate). The substitute-unused-filaments pass becomes a no-op for `plate=0` (no concept of "unused" when every plate counts), which is correct — in slice-all mode every slot the project defines IS used by something. The toggle is hidden on single-plate / STL sources where it'd be meaningless. **Cross-class slice-all is handled by a per-plate loop**: BS CLI's `--arrange` is project-wide, so `--slice 0 --arrange 1` on a cross-class source consolidates every plate's objects onto a single target bed — either packing everything onto one plate or rejecting with "Some objects are located over the boundary of the heated bed" when nothing fits. When Bambuddy detects `plate=0` combined with a class crossing, it falls back to slicing each plate independently (`plate=N, arrange=true`), then merges the N single-plate 3MF outputs into one multi-plate 3MF in `merge_plate_3mfs` — overlays each plate's `Metadata/plate_N.{gcode,gcode.md5,json,png,_small.png,no_light_N.png,top_N.png,pick_N.png}` onto the first plate's base 3MF and re-assembles `Metadata/slice_info.config` to list every plate's slice block. The resulting archive's totals are the sum of each plate's print time + filament usage. New `count_plates_in_3mf` parses `model_settings.config` for `` entries to know how many plate calls to make. Cost: N × per-plate slice time; for a 5-plate Mewtwo on H2D that's ~70s wall clock vs the single-call same-class path. **Progress toast shows loop position**: each per-plate sub-slice forwards the original `progress_request_id` + callback so the toast keeps showing the sidecar's stage messages, with the snapshot augmented with `multi_plate_index` / `multi_plate_count` — the toast renders "Plate 2 of 5 • Mewtwo.gcode.3mf — Generating G-code (47%) — 23s" instead of just elapsed time. New `slice.runningWithProgressMultiPlate` i18n key translated across all 9 locales. **Per-plate cover images preserved**: BS CLI with `--arrange` regenerates plate gcodes but rarely writes a fresh `Metadata/plate_N.png`, so the merged 3MF would have only plate 1's cover. The merger now takes the source 3MF as an optional fallback and lifts the source's per-plate render (`plate_N.png` / `plate_N_small.png`) into the merged file when the sliced output is missing it — same fallback approach as the archive-card thumbnail fix. **Final test coverage**: 26 unit tests in `test_slicer_3mf_convert.py` (extract canonical model, count plates, merge with overlay / passthrough / source-thumbnail fallback / sorted plates, substitute unused-slot filaments) + 3 in `test_slicer_api.py` (arrange flag wire format on preset and bundle paths) + 9 in `test_library_slice_api.py` (guard no-op semantics, re-sliced thumbnail / bed_type lifts, **a new cross-class slice-all integration test that mocks the sidecar, asserts the backend loops per-plate with `arrange=true`, and verifies the merged archive contains `plate_1..plate_N.gcode`**) + 2 in `test_archive_service.py` (Auxiliaries thumbnail fallback) + 4 in `SliceModal.test.tsx` (slice-all toggle sends `plate=0`, toggle hidden for single-plate, plus 2 pre-existing tests for the picked-plate behaviour) + 2 new in `SliceJobTrackerContext.test.tsx` (toast prefixes "Plate X of Y" when the snapshot carries the loop fields; no prefix on plain single-plate slices). 659 backend / 42 frontend tests green; backend ruff + frontend build + i18n parity all clean. 2 new tests in `SliceModal.test.tsx` (toggle sends `plate=0` to the backend; toggle hidden for single-plate sources) plus updates to the existing plate-picker test for the new label scheme. All 9 locales translated. Frontend build clean, i18n parity green at 4983 keys × 9 locales. - **System Health — log scanner that surfaces self-fixable issues before they become support tickets** — Complements the active Connection Diagnostic with a passive check: it scans Bambuddy's recent app log against a curated catalog of known failure signatures and reports what it finds. The catalog (`backend/app/services/log_health.py`) is a deliberate allowlist — only known-bad, actionable patterns match, so a healthy install reports nothing and noisy benign churn (the occasional MQTT reconnect after a Wi-Fi blip) is gated behind a per-signature `min_count` threshold. Six seed signatures cover the recurring "layer 8" causes from the closed-issue triage: rejected access code, FTPS :990 timeout, FTPS TLS handshake failure, flapping MQTT connection, unreachable camera (RTSPS :322), and SQLite `database is locked` contention. Each finding is deduped (`occurred N×, last seen …`), classified as *you can fix this* / *environment* / *please report this*, and carries a deep-link to the troubleshooting wiki; sample log lines are sanitized (IPs, serials, access codes redacted) before they leave the process. Exposed via `GET /system/health` and surfaced on two surfaces that share one `SystemHealthPanel` component: a System Health section on the System page (on-demand re-scan), and inline in the bug reporter when the form opens — so a setup mistake gets self-resolved instead of becoming a GitHub issue. The Add-Printer and Edit-Printer dialogs also gained a setup-time pre-flight: saving now runs the connection diagnostic and, if a check fails, warns with a "save anyway" escape hatch instead of silently saving a printer that will immediately show offline. Log-reading and redaction primitives were extracted from `routes/support.py` into a shared `backend/app/services/log_reader.py` (behaviour-preserving). 13 backend tests (`test_log_health.py`, `test_system_api.py`) and 8 frontend tests (`SystemHealthPanel`, `BugReportBubble`, `AddPrinterPreflight`, `EditPrinterPreflight`); all strings translated across the 9 locales. Backend ruff clean, full unit suite green, frontend build clean, i18n parity green. - **Event-loop stall watchdog — makes a frozen backend self-diagnose (#1486 groundwork)** — Several "container hangs after adding a printer" reports share a signature that leaves nothing to act on: the HTTP server goes silent, `/health` hangs, the process may stop responding to SIGTERM — and the logs just stop mid-stream with no traceback, because a frozen asyncio event loop cannot log anything. New `backend/app/services/loop_watchdog.py` closes that blind spot: an async heartbeat re-arms `faulthandler.dump_traceback_later()` every 10s, always 30s ahead. While the loop ticks, the timer is cancelled and re-armed before it can fire; if the loop stalls, the heartbeat can't re-arm and faulthandler's dedicated C-level timer thread — which runs independently of the frozen loop — dumps **every thread's stack to stderr**. The blocked frame then appears in `docker compose logs`, turning an un-diagnosable freeze into a one-command capture. Started in the app lifespan after migrations, stopped cleanly on shutdown; 30s threshold is well above any legitimate on-loop operation, so a trip always means a real bug. 5 unit tests in `test_loop_watchdog.py` (arms the timer, idempotent start, stop disarms + cancels, heartbeat interval below the threshold, survives a re-arm error). Backend ruff clean; full app lifespan verified via the integration suite. - **Slicer: process & filament profiles filtered by the selected printer (#1325, requested by @IndividualGhost1905)** — In the server-side Slice dialog, picking a printer profile now filters the Process and Filament dropdowns to presets compatible with that printer; presets that resolve to a different Bambu model drop into a trailing "Other printers" group instead of cluttering the main list. Matching uses the slicer's own `compatible_printers` list for imported (local) presets, and falls back to the `@BBL ` name suffix for cloud and standard presets, so all three tiers are covered. Compatibility-unknown presets (custom or untagged) are never hidden. Defaults follow suit — the pre-picked process and per-slot filament now prefer a printer-compatible preset, and switching the printer re-picks any selection left incompatible. The printer and process dropdowns also default to the preset names embedded in the source 3MF's `project_settings.config` when those presets are available, instead of always taking the first listed preset. New `frontend/src/utils/slicerPrinterMatch.ts` (11 unit tests) and `extract_embedded_presets_from_3mf` (5 unit tests); `UnifiedPreset` now carries `compatible_printers`, exposed for the local tier (`backend/app/api/routes/slicer_presets.py`); the plates endpoints return `embedded_printer` / `embedded_process`. Parity green, build clean. - **Spanish (es) translation (#1243, requested by @MiguelAngelLV)** — Bambuddy now ships a full European Spanish locale. New `frontend/src/i18n/locales/es.ts` translates all 4899 keys with placeholders, plural forms, and inline markup preserved; registered in `frontend/src/i18n/index.ts` and selectable as "Español" in the language picker. The parity checker auto-discovers the file — `frontend/scripts/check-i18n-parity.mjs` gained an `ES_COGNATES` allow-list for genuine Spanish cognates and brand/format tokens. Brings the supported-language count to 9 (en / de / es / fr / it / ja / pt-BR / zh-CN / zh-TW). Parity green, frontend build clean. - **Currency: Belize Dollars (BZD) added to the Settings → Cost currency dropdown (#1454, requested by @PLGuerraDesigns)** — Reporter accurately tracks 3D-printing filament costs in his local currency and BZD wasn't selectable, forcing a manual 2:1 mental conversion from USD. Added `BZD: 'BZ$'` to `frontend/src/utils/currency.ts` next to MXN (Americas dollar-prefix grouping); `getCurrencySymbol('BZD')` returns `'BZ$'` and the SUPPORTED_CURRENCIES list now has 30 entries. Unit test added in `frontend/src/__tests__/utils/currency.test.ts` covering the symbol lookup and presence in SUPPORTED_CURRENCIES; entry-count assertion bumped to 30 so any future additions/removals are caught immediately. 14 currency tests green; frontend build clean. - **Connection Diagnostic — self-service triage for "printer won't connect / won't print"** — A triage review of recently-closed issues found roughly a third were user-side setup errors (printer not in LAN developer mode, blocked ports, Docker bridge networking, wrong access code, printer on a different subnet), each costing a multi-round-trip "enable debug logging → build a support bundle → upload it" exchange. A new diagnostic (`backend/app/services/printer_diagnostic.py`) runs those checks automatically: TCP reachability of MQTT 8883 / FTPS 990 / RTSPS 322, LAN developer mode, Docker network mode, printer/host subnet match, and MQTT credential class — each returning a pass / fail / warn / skip status with a localized plain-language fix. Exposed via `GET /printers/{id}/diagnostic` (saved printer) and `POST /printers/diagnostic` (pre-save Add-Printer flow), and surfaced as a one-click "Run diagnostic" from the printer card actions menu (plus a quick button on the card when a printer is offline), the Add-Printer dialog, and a new Connection Diagnostic section on the System page. The in-app bug reporter scans configured printers when the report form opens and always shows the result — a healthy confirmation when nothing's wrong, or the detected problem and its fix inline — so setup mistakes get self-resolved instead of becoming GitHub issues. The GitHub `config.yml` troubleshooting link was repointed from the wiki source repo to the rendered troubleshooting page. Backend service unit tests (15) and frontend modal tests (3) added; all diagnostic strings translated across the 8 locales. Backend ruff clean, frontend build clean, i18n parity green. ### Changed - **Settings → SpoolBuddy: CPU load tile added to the device card** — The SpoolBuddy daemon's heartbeat already reports `load_avg` (1/5/15 min) and `cpu_count` via `system_stats` (see `spoolbuddy/daemon/system_stats.py`), but the device card on the Bambuddy SpoolBuddy settings only rendered CPU temp / memory / disk / system uptime. Adds a fifth tile next to CPU temp showing the 1-minute load average alongside core count and a percent-of-cores readout — for a 4-core Pi: `1.20 / 4 (30%)`. Falls back to a bare load number when `cpu_count` isn't reported, and the tile is hidden entirely when the daemon doesn't emit `load_avg` (older builds). Useful for spotting the "I2C/SPI stuck after idle overnight" pattern early — sustained high load before the bus dies points at runaway daemon work rather than a kernel hang. Translated across all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW). Frontend build clean, i18n parity green. - **Virtual printer: setup diagnostic + one-click slicer-certificate export** — Two recurring virtual-printer support pains, addressed on the Virtual Printers settings page. **(1) Setup check** — a new stethoscope action on each VP card runs `GET /virtual-printers/{id}/diagnostic` and shows a pass/fail/warn/skip checklist: VP enabled, services running, bind interface still exists, access code set, target printer (proxy mode), and — decisively — a live TCP probe of the FTP/MQTT/discovery ports on the bind IP. The manager swallows per-service start errors (`run_with_logging`), so a service object can exist while nothing is actually listening; probing the bind IP from outside is the only reliable signal, and it catches the common "VP doesn't show up in the slicer" bind-IP-conflict and stale-interface cases. New `backend/app/services/virtual_printer/diagnostic.py` + `VPDiagnosticResult` schema + `VirtualPrinterDiagnosticModal.tsx`. **(2) Slicer certificate** — virtual printers present a TLS cert signed by a shared CA the slicer must trust; until now users had to `docker exec` in and `cat bbl_ca.crt` to get it. A new "Slicer certificate" row on the Virtual Printers settings card (alongside the Archive name source toggle) offers Copy and Download (`bambuddy-virtual-printer-ca.crt`) plus the CA's SHA-256 fingerprint, served by `GET /virtual-printers/ca-certificate` — only the public certificate, never the CA private key. The CA is generated on demand so the button works before the first VP is enabled. Copy uses a non-secure-context fallback (Bambuddy is usually on plain-HTTP LAN), extracted into a shared `utils/clipboard.ts`. 9 backend diagnostic/CA unit tests + 4 route integration tests + 6 frontend tests (diagnostic modal, clipboard helpers); all `vpDiagnostic.*` / `virtualPrinter.caCert.*` strings translated across the 9 locales. Backend ruff clean, frontend build clean, i18n parity green. - **Bug-report panel: connection diagnostic no longer overflows on multi-printer setups** — The "Report a Bug" panel scans every configured printer on open and surfaces connection problems inline so users can self-fix before filing. The first cut rendered a full ~6-row checklist for *each* problem printer stacked vertically; a user with many printers all reporting issues pushed the description box, screenshot uploader and Submit button far below the fold in the `max-w-md` / `max-h-[80vh]` panel. The diagnostic section is now a compact summary — one line ("N of M printers have connection issues") followed by the affected printers as collapsed rows (healthy printers count toward M but render no detail). Each row expands on demand to that printer's full checklist via the shared `Collapsible` widget; when exactly one printer has problems the row is auto-expanded since that's the case where inline detail is wanted with no extra click. The panel now stays a fixed ~3 lines plus one row per affected printer regardless of fleet size, keeping the report form reachable. Healthy-fleet confirmation line is unchanged. New `bugReport.diagnosticSummary` key (with `{{problems}}`/`{{total}}`) replaces the static `diagnosticHeading`; `diagnosticIntro` reworded to be printer-count-neutral and point at the expand affordance — both translated across all 9 locales. 2 new tests in `BugReportBubble.test.tsx` (multiple problems stay collapsed and expand on click; a single problem auto-expands); 11 tests green; frontend build clean; i18n parity holds at 4903 keys × 9 locales. - **Color Catalog sync now identifies itself as Bambuddy to filamentcolors.xyz** — The FilamentColors.xyz sync client in `inventory.py` created its `httpx.AsyncClient` with no `User-Agent`, so it leaked httpx's default `python-httpx/x.y` string — the only outbound client that did (`bambu_cloud`, `makerworld`, `firmware_check` all send the honest `Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)`). It now sends the same honest UA, consistent with the rest of the codebase. Surfaced while investigating #1456 (a Cloudflare `403` on the sync that turned out to be the reporter's network/IP reputation, not Bambuddy — the UA leak was a separate inconsistency found in passing, and this change does not by itself resolve a Cloudflare IP block). - **Filament inventory: grouped rows now show group totals (#1368, requested by a user)** — With "Group similar" enabled, the collapsed group row showed the values of a single member (the first spool) — so a group of five 1 kg spools displayed "1000 g" instead of the 5 kg it actually held. The group header now aggregates across all members: the table view's Label, Net, Gross, Used and Remaining columns and the grid card's weight figure show group totals, while identity columns (Material, Brand, Colour) and the Cost/kg rate stay per-spool-correct. Per-spool-only fields with no meaningful total (dates, location, note, tag ID) keep showing the representative member's value; the expanded individual rows are unchanged. New `aggregateGroupSpool` helper in `frontend/src/utils/inventoryGrouping.ts` with 4 unit tests. Frontend-only — all data was already in the spool list. — Previous behaviour disabled the Slice button whenever the source 3MF's bound printer model didn't match the user's picked printer profile, on the theory that the slicer CLI "cannot re-slice a 3MF for a different printer" and would silently fall back to embedded settings to produce a wrong-printer file. Step 0 empirical test on 2026-05-20 disproved that: an 18-color H2D-bound `Trent900.3mf` sliced via the X1C bundle (`POST /slice` with `bundle=cb…X1C, printerName=# Bambu Lab X1 Carbon 0.4 nozzle`) produced 2.3 MB of genuinely X1C-compatible G-code in 1.8 s — `printer_model` overridden to `Bambu Lab X1 Carbon`, `printable_area` to 256×256 (X1C bed, not H2D's 350×320), `printable_height` 250 (vs 325), `bed_exclude_area` populated with X1C's 18×28 corner zone, `nozzle_diameter` single 0.4 (vs H2D's dual `0.4,0.4`), and the full X1C `machine_start_gcode` sequence baked in. The sidecar takes printer / process / first-N filament names from the picked bundle and only inherits embedded values for unused trailing slots — bed size, kinematics, start sequence all come from the target. **Behavioural change**: dropped `!printerMismatch` from the SliceModal `isReady` predicate so the Slice button stays enabled when models differ. The amber banner was first softened to an info message, then removed entirely — re-slicing across printers is now just a normal slice, the picker UI already shows which printer was picked, no second confirmation needed. **Dead-code removal (same drop)**: with no banner, the `source_printer_model` field on the `/library/files/{id}/plates` and `/archives/{id}/plates` responses had zero consumers; the `extract_source_printer_model_from_3mf` helper in `threemf_tools.py` (which opened the 3MF zip and read `Metadata/project_settings.config` on every plate request) had zero callers. Removed both response keys, both backend extractions, both `threemf_tools` imports, the helper itself, its 6 unit tests, the `source_printer_model` field from `frontend/src/types/plates.ts` (PlateMetadata + LibraryFilePlatesResponse), and 2 obsolete SliceModal tests that exercised the now-impossible matched-printer / legacy-archive paths. **i18n discipline cleanup (same drop, per [[feedback_no_followups]] + [[feedback_translate_dont_fallback]])**: every t() callsite in SliceModal.tsx had an inline English `defaultValue:` or positional-second-arg English fallback — 22 sites in total. With 8 locales shipped, those fallbacks are dead weight at best, and an actual i18n-violation when the key is missing because non-English users would silently see English. Audit found 3 keys (`slice.bundle`, `slice.bundleNone`, `slice.bundleAllRequired`) that had **no** corresponding entry in any locale file — they were being served from the inline English fallback exclusively, meaning every non-English user was already seeing those three labels in English. Added all 3 to all 8 locales with real translations, then stripped the English fallback from every t() call in SliceModal.tsx. The `slice.printerMismatch` key was removed from all 8 locales (banner is gone). **Why this matters**: a recurring pain point for users importing MakerWorld project files where the original creator's printer often differs from the user's; previously they had to round-trip through BambuStudio's "convert project" flow to re-export. Now Bambuddy re-slices in-place with no UI friction. **Tests**: the existing SliceModal "shows mismatch warning AND disables Slice" test was rewritten to assert "does not surface any cross-printer banner AND keeps Slice enabled when models differ" (regression guard against the gate being re-added); 2 obsolete tests deleted. 32 SliceModal tests green (was 34, -2 dead tests); 49 threemf_tools tests green (was 55, -6 helper tests); 24 plates-route tests green; frontend build clean; backend ruff clean; i18n parity check passes 4858 keys × 8 locales (net +2 vs pre-fix: +3 bundle keys, -1 printerMismatch). ### Security - **idna: bump to `>=3.15` to clear CVE-2026-45409 (ReDoS in `idna.encode()` with crafted Unicode payloads, e.g. `"٠" * N` or `"・" * N + "漢"`)** — Transitive dep pulled in by anyio / httpx / requests / yarl; not directly pinned, which is why it lingered at 3.13. Added an explicit `idna>=3.15` floor in `requirements.txt` between Authentication and HTTP-client blocks with a comment explaining why it's pinned (so a future downstream loosening doesn't silently downgrade us). Verified via `pip-audit` clean post-upgrade. - **starlette: bump floor to `>=1.0.1` to clear PYSEC-2026-161** — `starlette` is a transitive dep pulled in by fastapi, whose range still admits the vulnerable 1.0.0 build, so a fresh `pip install` would silently pick it back up. Added an explicit `starlette>=1.0.1` floor in `requirements.txt` under the urllib3 pin with a why-comment matching the same pattern as the idna/urllib3 entries. Release-notes reviewed for both 1.0.1 (single fix: ignore malformed `Host` header when constructing `request.url`) and 1.1.0 (the resolver actually picked up 1.1.0): three behavioural changes — `FileResponse` falls back to `application/octet-stream` when `mimetypes.guess_type()` can't resolve (Bambuddy has 2 `FileResponse` calls without explicit `media_type`, both serving `index.html` where guess_type still resolves to `text/html`, plus custom-icon serving in `external_links.py:261` where the new fallback is a security improvement), `HTTPEndpoint` only dispatches standard HTTP verbs (`grep` found zero `HTTPEndpoint` usages in Bambuddy — pure FastAPI router code), `StaticFiles.lookup_path` rejects absolute paths in *requests* (the 4 mounts in `main.py:5503-5525` pass absolute *base directories* to the constructor, which is unaffected — only path-traversal-style request paths get rejected). Full backend test suite green (5300/5301; the 1 failure is a pre-existing `-n 30` parallelism flake unrelated to starlette and passes in isolation). Verified clean via `pip-audit` post-upgrade. - **PyJWT CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): permanently ignored in pip-audit** — Advisory is disputed by the PyJWT maintainers, with the advisory description literally noting *"this is disputed by the Supplier because the key length is chosen by the application that uses the library."* `fix_versions=[]` on the advisory confirms no PyJWT patch exists or will exist. Bambuddy is not affected: `backend/app/core/auth.py:184` auto-generates secrets via `secrets.token_urlsafe(64)` (~86 chars of entropy, far above any sane minimum) and the file-loaded path at `:177` rejects secrets shorter than 32 chars. Added a permanent `--ignore-vuln CVE-2025-45768` to `.github/workflows/security.yml` with an inline comment citing the file:line evidence so a future maintainer reviewing the ignore list sees why it's load-bearing. Also dropped the stale `--ignore-vuln CVE-2026-4539` for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified: `pip-audit --ignore-vuln CVE-2025-45768` alone reports clean). ### Fixed - **Support bundle + bug-report submission now include the live diagnostic snapshot** — Three diagnostics (Connection Diagnostic per printer, Virtual Printer Setup Diagnostic per enabled VP, Log Health Scanner) have shipped on the System page and inline in the bug-report bubble since 6bc6a1d6 / e222a0ef / ed31b8f4, but the results were only ever shown to the *user* — never persisted into the downloadable support ZIP or the submitted GitHub issue. A report saying "looks broken in Bambuddy" arrived with no actionable signal beyond raw logs. **Fix**: new `services/diagnostic_snapshot.collect_diagnostic_snapshot` runs all three concurrently with an outer per-probe 15 s wall-clock cap (so a hung interface adds at most ~15 s to bundle generation regardless of fleet size — `asyncio.gather`, total ≈ max(per-cap) not sum). Fail-soft per probe: a crash inside one printer's check emits `{"printer_id": N, "error": "..."}` for that entry rather than nuking the whole snapshot — partial result beats a 500. Wired into `_collect_support_info()` so both flows (`POST /support/bundle` and `POST /bug-report/submit` via `support_info=...`) pick up the new `diagnostics` top-level key without their own changes. **Private-data sanitization** — the diagnostic schemas embed raw IPv4 in three places (`PrinterDiagnosticResult.ip_address`, network-mode check's `params.{printer_ip, host_ip}`, VP diagnostic's `params.bind_ip`), and the snapshot adds printer names. None of those should leak. The snapshot now runs a recursive sanitizer on the full result tree before returning: known DB-listed values (printer name, IP, serial, access code) get the same `[PRINTER]/[IP]/[SERIAL]/[ACCESS_CODE]` labels the log sanitizer already applies (via the shared `collect_sensitive_strings`), and an IPv4-regex fallback catches IPs the DB doesn't know about — most importantly the Bambuddy host IP returned by `_get_host_ip()` and any VP `bind_ip` the user picked at setup. Live-DB smoke test confirms zero raw IPv4 instances in the serialized snapshot output. **Progress indicators**: the bubble's "submitting" view and the System page's Download button now render a static four-line checklist showing what's running (printer connectivity → VP setup → log scan → submit/build ZIP) — communicates the longer wait honestly without faking server-side phase progress we can't actually track. **Tests**: 6 new in `test_diagnostic_snapshot.py` — empty-input shape stable, per-printer / per-VP result coverage, fail-soft on a single-probe crash, `timed_out` marker when a probe exceeds the per-probe cap (test patches the cap to 0.05 s), end-to-end IP sanitization across all five field shapes (top-level `ip_address`, `printer_ip`, `host_ip`, `bind_ip`, plus IPs embedded in log-health sample lines) with a final regex sweep over the JSON-serialized result asserting zero raw IPv4 escapes, concurrent execution proof (4 × 0.2 s probes complete in < 0.5 s, would be 0.8 s sequential). Existing 27 BugReportBubble + SystemInfoPage frontend tests still pass; 9-locale i18n parity check clean (4993 leaves per locale, 9 new keys added with real translations everywhere — no English fallback). Backend ruff clean. - **"Prefer Lowest Remaining Filament" now uses Bambuddy's inventory weight, not just the printer's RFID counter (#1508, reported by @kleinwareio)** — Reporter has an inventory spool cloned to slot 1 and the original (much further used) in slot 4 of the same P1S AMS, with the preference enabled, and the dispatch consistently picked slot 1 (the fresh clone) instead of slot 4 (the original they wanted to finish first). Root cause is the `prefer_lowest` sort in `_match_filaments_to_slots` (`print_scheduler.py`): the sort key reads `f.get("remain", -1)` straight out of `_build_loaded_filaments`, which sources it from MQTT AMS `tray.remain` — the printer firmware's own RFID-decremented value. Two problems with that signal: (a) it's only populated for Bambu RFID spools, so every non-RFID / 3rd-party / user-loaded tray reports `-1` and gets clamped to a sentinel — multiple non-RFID spools then tie in the sort and Python's stable sort collapses to AMS-slot insertion order, so slot 1 always wins; (b) even when set, it's the *printer's* counter, not Bambuddy's `label_weight - weight_used` (internal mode) or Spoolman's `remaining_weight` (Spoolman mode) — the two diverge any time the user re-spools, swaps cardboard, or runs a print outside Bambuddy. The reporter is on internal-inventory mode with non-RFID spools — both failure modes apply, hence slot 1 every time. **Fix**: when a slot is bound to a Bambuddy / Spoolman spool, that inventory record's remaining weight becomes the sort signal. New async helper `_build_inventory_remain_overrides(db, printer_id, loaded)` returns `{global_tray_id: remaining_grams}` for slots with an assignment — internal mode joins `SpoolAssignment` → `Spool` once per dispatch, Spoolman mode joins `SpoolmanSlotAssignment` then fetches each spool through the existing `_spoolman_remaining_grams` (shared with `filament_deficit.py`, parity rule per [[feedback_inventory_modes_parity]]). The new `_prefer_lowest_sort_key` consumes that map alongside the legacy MQTT field with a **two-tier** comparison: inventory-tracked spools always sort BEFORE MQTT-only spools, then ascending by remaining within each tier, then ascending by `ams_id * 4 + tray_id` as the deterministic slot tie-breaker. The tier flag dominates so we never compare grams (inventory) against percent (MQTT) — no unit-conversion contortions. MQTT-only behaviour is preserved exactly: `remain = -1` still maps to the 101 sentinel and slot order still decides on ties, so users who haven't bound any spools see no change. External / VT tray slots are skipped (tracked separately from AMS bindings). Lookup runs only when `prefer_lowest_filament` is enabled — no extra DB hit for users who don't use the preference. **Tests**: 6 new in `TestPreferLowestInventoryOverride` in `test_scheduler_ams_mapping.py` (inventory override beats MQTT remain — the literal reporter scenario with 950 g clone vs 50 g original; zero-grams still sorts first within its tier; inventory tier beats MQTT tier regardless of value; tied inventory grams break to lower slot; no-override falls through to MQTT — regression guard for un-tracked spools; legacy `remain = -1` still sentinel-sorts last when override map is None) + 7 new in `test_scheduler_inventory_remain.py` covering `_build_inventory_remain_overrides` directly (internal mode returns label_weight − weight_used per bound slot; external slots skipped; empty loaded short-circuits; over-consumed spool clamps to 0 g; unbound slots absent from map; Spoolman mode uses `_spoolman_remaining_grams` for parity; Spoolman unreachability silently omits that slot). 102 scheduler + inventory tests green; backend ruff clean. - **X1/H2/P2 live camera no longer fails with `Address already in use` on transitional ffmpeg builds (#1504, reported by @rage03usa, confirmed by @PawseHaxor)** — On a native Ubuntu install with the Jammy-era system ffmpeg, the RTSP live-view path retried indefinitely with `Unable to open RTSP for listening … Address already in use`. Snapshots, the camera diagnostic, and OrcaSlicer all kept working — only live view was broken. Cause: the ffmpeg argv built in `backend/app/api/routes/camera.py` (added in 530a7a46 as part of an RTSP-stability bundle) passed `-timeout 30000000`. That ffmpeg version *deprecated* the original `-timeout` (socket I/O microseconds) and repurposed the name to mean the *RTSP listen-mode incoming-connection timeout* — any non-zero value **implies `-listen`**. ffmpeg then flipped into RTSP server mode and tried to bind the same localhost port Bambuddy's TLS proxy was already listening on, hence EADDRINUSE on every retry (the odd-port pattern @PawseHaxor noticed is coincidence — the ephemeral allocator just picked odd values that run). The reporter's own workaround (drop the option) works but silently loses the socket-level read timeout, so a hung TLS handshake would block past the OS TCP timeout instead of failing fast into the existing reconnect loop. **Why this can't be a one-line literal swap**: ffmpeg has shipped *three* arrangements of this option over time and Bambuddy supports the full range. Pre-deprecation builds: `-timeout` is the socket I/O timeout. Transitional builds (~late-4.x, what the reporter is on): `-timeout` is the broken listen-mode option, `-stimeout` is the replacement. **Modern ffmpeg (5.x / 6.x / 7.x — current Debian 13, Ubuntu 24.04, current Homebrew)**: `-stimeout` was removed entirely and `-timeout` is back to socket I/O. So both literals regress one half of the install base. **Fix**: a new `rtsp_socket_timeout_flag()` helper in `backend/app/services/camera.py` probes `ffmpeg -h demuxer=rtsp` once at first use and picks `-stimeout` when ffmpeg advertises it (transitional window) or `-timeout` otherwise (modern + very old). The result is cached for the process lifetime — ffmpeg won't swap mid-run. The function returns the option name without a leading dash so callers prepend it themselves (no empty-flag formatting bug). Wired into both RTSP ffmpeg call sites — `routes/camera.py` (printer camera) and `services/external_camera.py` (external RTSP) — in lockstep, same TLS-proxy + ffmpeg pattern, same regression. The reporter had tried `-listen_timeout` (doesn't help — we *don't* want listen mode) and `-rw_timeout` (AVIO-level, RTSP demuxer doesn't honour it on its control socket), but no manual swap could be correct for both transitional and modern installs simultaneously. **Tests**: 8 in `test_ffmpeg_rtsp_timeout_flag.py` — 6 unit tests for the probe (picks `-stimeout` when advertised, falls back to `-timeout` on modern, defaults to `-timeout` when ffmpeg missing or probe raises, caches across calls, substring-match guard against false-positives on `-listen_timeout`), 2 parametrised regression guards against either RTSP ffmpeg argv re-hard-coding a literal flag instead of consuming the probe. 37 (probe + existing external-camera) tests green; backend ruff clean. - **SliceModal: process / filament dropdowns now filter by nozzle diameter too, not just printer model (#1325 follow-up #2, reported by @IndividualGhost1905)** — With the @BBL name fallback in place, the reporter saw that an X2D 0.4 selection still mixed 0.2 / 0.6 / 0.8 nozzle process variants into the main list. The fallback's regex stripped any trailing ` nozzle` suffix from both sides before comparing, so `"Bambu Lab X2D 0.4 nozzle"` and `"0.40mm Strength @BBL X2D 0.8 nozzle"` both reduced to `"X2D"` and matched. The bundle path was already nozzle-correct (a `.bbscfg` is scoped to one printer-preset-name including its nozzle, so the bundle-side exact-match was nozzle-aware); only the name fallback needed fixing. **Fix**: `extractPrinterPresetModel` and `extractBblToken` now each return `{ model, nozzle }`. The nozzle is the parsed string ("0.4" / "0.6" / etc.) or `null` when the name has no suffix. `classifyByBambuName` treats a `null` process nozzle as `"0.4"` — Bambu's convention is to omit the suffix on 0.4 (the default) and include it for 0.2 / 0.6 / 0.8, exactly as the reporter described. Both `model` and `nozzle` must compare equal for a `'match'`; differing nozzles fall into the existing "Other printers" group, no new group label needed. If the selected printer preset name has no parseable nozzle (non-Bambu / hand-typed), the matcher degrades to model-only — Bambu printer presets always include nozzle in practice, so this is defensive. **Tests**: 9 new in `slicerPrinterMatch.test.ts` covering the matrix (0.4 printer vs no-suffix / 0.6 / 0.8 process; 0.6 printer vs 0.6 / no-suffix-=-0.4; explicit 0.4-suffix-on-process still matches 0.4 printer; same rule on filament presets; wrong-model dominates over matching-nozzle; no-nozzle printer name degrades to model-only); one existing test reframed (the case that previously asserted a 0.6-nozzle process matched a 0.4 printer — the exact bug — now asserts mismatch). 46 slicerPrinterMatch + 34 SliceModal tests green; frontend build clean. - **Timelapse now attaches to the archive after a backend restart mid-print (#1485 follow-up, reported by @pwostran)** — With the duplicate-archive fix from #1485 in place, a restart mid-print stopped creating ghosts — but the resulting archive came back without its timelapse video (only the finish snapshot was attached). Cause is a side-effect of the #1304 first-push guard: on the first MQTT push after Bambuddy starts (`_previous_gcode_state = None`), `is_new_print` is deliberately False so `on_print_start` doesn't fire — which prevents duplicate archive creation **but also** prevents the timelapse-baseline capture, since both live behind the same callback. At PRINT COMPLETE, `_scan_for_timelapse_with_retries` finds an empty `_timelapse_baselines` for the printer and falls into the "take baseline now" fallback in `main.py`. By that point the printer has already uploaded the in-flight MP4, so the snapshot includes it. Every retry then reports "N files found / no new files since baseline" and the scan gives up. The reporter's support bundle is the smoking gun — pre-reboot baseline of 7 files, post-reboot fallback baseline of 8 files (including the just-uploaded one), 4 retries all unable to see the diff. **Fix**: `bambu_mqtt.py` now fires a sibling `on_print_running_observed` callback inside the "Now tracking RUNNING state" branch when the first-push guard suppresses `on_print_start`. `main.py` wires it to a thin handler that fetches the printer row from DB and calls the existing `_capture_timelapse_baseline_at_start`. The callback only fires the first time we observe RUNNING per session (gated on the same `not self._was_running` branch the timelapse-flag restore already lives in), so a normal print start path is unaffected. The handler is also idempotent: if a baseline already exists for that printer, it returns without touching it. Safe because the printer doesn't upload the timelapse until *after* PRINT COMPLETE, so a baseline captured any time during the in-flight print is still pre-upload — no narrow window. The plumbing (`set_print_running_observed_callback` setter, in-`connect_printer` wrapper, constructor pass-through) mirrors the existing `on_print_start` / `on_print_complete` callback chain in `printer_manager.py`. **Tests**: 7 new in `TestPrintRunningObservedCallback` in `test_bambu_mqtt.py` (fires on first RUNNING after startup, doesn't double up with `on_print_start`, fires only once per session, skips on non-RUNNING / missing file / no-callback-set, payload shape mirrors `on_print_start`); 3 new in a dedicated `test_timelapse_baseline_restart_recovery.py` (handler captures the printer's existing-videos snapshot into `_timelapse_baselines`, skips when a baseline already exists, skips when the printer row was deleted between push and handler). 336 MQTT + print-start + timelapse tests green; backend ruff clean. - **SliceModal: process / filament dropdowns now filter for users who haven't uploaded slicer bundles (#1325 follow-up, reported by @IndividualGhost1905)** — The original #1325 fix replaced a stale hardcoded `@BBL ` allow-list with bundle-based compatibility: a process / filament preset was classified against the selected printer by consulting the user's uploaded Slicer Bundles (.bbscfg). That works perfectly for users who have uploaded bundles for every printer their cloud catalogue covers — and silently no-ops for everyone else: every cloud preset resolves to `'unknown'`, nothing moves into "Other printers", and the dropdown looks identical to the pre-fix state. **Fix**: restored the `@BBL ` name fallback as a third tier *below* the bundle path, but with the token-to-printer mapping driven by **the backend's canonical `PRINTER_MODEL_MAP`** (`backend/app/utils/printer_models.py`) instead of a duplicated frontend table. A new `GET /api/v1/slicer/printer-models` route ships the mapping unmodified; `slicerPrinterMatch.buildCompatibilityIndex` accepts it as a second arg, inverts it into a short-code → display-fragment table (`X1C` → `X1 Carbon`, `P2S` → `P2S`, `A1 Mini` → `A1 mini`, …), and `presetCompatibility` uses it only after `compatible_printers` and the bundle index have already returned `'unknown'`. The match is case- and whitespace-insensitive (`"A1 mini"`, `"A1 Mini"` and `"a1mini"` all compare equal). When the registry doesn't list a token, the matcher falls back to comparing the raw token against the printer-preset model fragment — so a brand-new "Q1" printer with `@BBL Q1`-tagged presets matches without any code change. Adding a new model only requires updating the existing backend `PRINTER_MODEL_MAP` (already the single source of truth for `is_dual_nozzle_model`, the rod-type/ethernet registries, and 3MF metadata normalisation) — no frontend table to keep in sync. **Tests**: 2 new in `test_slicer_presets.py` (`/printer-models` returns the full `PRINTER_MODEL_MAP`; the route hands back a copy, not the live module dict); the existing 25 `slicerPrinterMatch.test.ts` cases were extended to 36 covering: registry-driven X1C vs X1 Carbon match, A1 vs A1 mini disambiguation, H2D vs H2D Pro disambiguation, the previously-missing P2S / H2C / H2S / X2D, raw-token fallback for unregistered models, graceful degradation when the registry fetch hasn't resolved yet, the `compatible_printers`-wins-over-name rule, and the bundle-wins-over-name rule. 38 slicer-presets + 36 slicerPrinterMatch tests green; backend ruff clean; frontend build clean. - **Cloudflare-fronted Bambuddy no longer needs an `unsafe-inline` override to load (#1460 follow-up, reported by @Soopahfly)** — A Bambuddy instance behind Cloudflare logged an inline-script CSP violation on every page load: Cloudflare's bot-detection script (`/cdn-cgi/challenge-platform/scripts/jsd/main.js`) is injected into the HTML on the edge with a hash that changes per request, so it can never be allowlisted by `script-src` hash. The contributor's workaround was to relax `script-src` to `'unsafe-inline'` in their Nginx Proxy Manager — which works but defeats most of the CSP. **Fix**: the SPA CSP now stamps a fresh per-request **nonce** into `script-src` (`'self' 'nonce-'`). Per [Cloudflare's documented behaviour](https://developers.cloudflare.com/cloudflare-challenges/challenge-types/javascript-detections/#if-you-have-a-content-security-policy-csp), when a nonce is present in the CSP header Cloudflare clones the same nonce onto its injected `