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