From ed08ed3787ed259ac5c599a36660cd443b21f758 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sat, 23 May 2026 14:52:04 +0200 Subject: [PATCH] fix(timelapse): capture baseline on restart-recovery so post-reboot timelapses attach (follow up issue #1485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Bambuddy is restarted mid-print, the first MQTT push from the printer carries `_previous_gcode_state = None`. The #1304 guard deliberately suppresses on_print_start on that first push to prevent duplicate archive creation — but on_print_start is also where _capture_timelapse_baseline_at_start runs, so the in-memory _timelapse_baselines dict stays empty for the resumed session. At PRINT COMPLETE, _scan_for_timelapse_with_retries finds no baseline and falls into its "take baseline now" fallback. By that point the printer has already uploaded the in-flight MP4, so the snapshot includes the new file. Every "Found N files / no new files since baseline" retry then fails to detect a diff, and the archive ends up with no timelapse attached — pwostran's report (#1485 follow-up): card shows the finish snapshot but no video. Add a sibling callback on_print_running_observed that bambu_mqtt fires in the "Now tracking RUNNING state" branch when on_print_start was suppressed. main.py wires it to a thin handler that looks up the printer row and calls the existing _capture_timelapse_baseline_at_start. Idempotent — skips if a baseline already exists (handles the rare same-session race where on_print_start also fires for some reason). The printer doesn't upload the timelapse until after PRINT COMPLETE, so a baseline captured any time during the print is still pre-upload — no narrow window to hit. Verified against the in-the-field logs in #1485 (pwostran's 2026-05-23 support bundle): pre-reboot: baseline = 7 files reboot post-reboot completion: fallback baseline = 8 files (includes new MP4) -> all 4 retry attempts report "no new files since baseline" 10 new tests cover both the MQTT-side fire decision (fires when suppressed, doesn't fire when on_print_start handles it, once per session, payload shape mirrors on_print_start) and the main.py handler (snapshot capture, double-capture guard, missing-printer-row guard). --- CHANGELOG.md | 1 + backend/app/main.py | 47 +++++ backend/app/services/bambu_mqtt.py | 37 ++++ backend/app/services/printer_manager.py | 15 ++ .../tests/unit/services/test_bambu_mqtt.py | 191 ++++++++++++++++++ ...est_timelapse_baseline_restart_recovery.py | 152 ++++++++++++++ 6 files changed, 443 insertions(+) create mode 100644 backend/tests/unit/test_timelapse_baseline_restart_recovery.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c14e0c98..a97152c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ All notable changes to Bambuddy will be documented in this file. - **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 +- **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 `