diff --git a/CHANGELOG.md b/CHANGELOG.md index 09c85852f..446fccc25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b1] - Unreleased +### Fixed +- **Archives: timelapse auto-attach now works for VP-queue / dispatch prints (#1403 follow-up, reported by @pwostran)** — Bambuddy uses a snapshot-diff strategy to pick the right MP4 off the printer's SD card after a print (Bambu printers in LAN-only mode don't sync NTP, so file mtimes are unreliable — `_scan_for_timelapse_with_retries` snapshots existing video filenames at print start and looks for any NEW filename at completion). The baseline-capture call was inline at the bottom of `on_print_start`'s **new-archive branch only** — the **expected-archive branch** (which queue / VP-dispatched / reprinted jobs take, anything registered via `register_expected_print`) exited at its own `return` without ever snapshotting. So queue prints had `_timelapse_baselines[printer_id]` unset; the completion-time scan fell into its "take baseline now" fallback that snapshots the SD card *after* the new MP4 has already landed → the new file sits in the "baseline" set → no diff ever matches → auto-attach silently does nothing. The reporter's `bambuddy-support-20260518-185935.zip` shows the failure verbatim: `Using expected archive 3 for print (skipping duplicate)` at `18:41:10`, `Timelapse was active during print, scheduling auto-scan for archive 3` at `18:58:55`, and `[TIMELAPSE] Archive 3 has no printer, aborting` (a separate bug already fixed by the printer_id-assignment commit in this same train) — and `grep -i baseline` across both his bundles returns zero hits, confirming the snapshot never ran. Fix extracts the inline baseline-capture into `_capture_timelapse_baseline_at_start(printer, printer_id, logger)` and calls it from BOTH branches of `on_print_start` (mirroring the existing site in the new-archive branch with a matching call just before the expected-archive branch's `return`). Helper is best-effort with a `try / except Exception` wrapping `_list_timelapse_videos`, so a transient FTP failure at print-start logs `[TIMELAPSE] Failed to capture baseline at print start: …` and the print proceeds — the completion-time fallback still kicks in (with its known limitation), behaviour matching what the new-archive branch had all along. **Tests:** new `test_expected_archive_path_captures_timelapse_baseline` in the existing `test_print_start_assigns_printer_id_to_vp_archive.py` patches `_list_timelapse_videos` to return two pre-existing videos, runs `on_print_start` through the expected-archive branch, and asserts `_timelapse_baselines[1] == {"earlier_print_a.mp4", "earlier_print_b.mp4"}` — a future refactor that removes the call from one of the branches now fails CI. The existing 2 regressions (`test_expected_archive_path_assigns_printer_id_when_unset`, `test_expected_archive_path_preserves_existing_printer_id`) plus 50 adjacent expected-archive / layer-timelapse / archive-filtering tests still green. The fixture clearing `_expected_prints` etc also clears `_timelapse_baselines` now so test isolation holds. + ## [0.2.4.1] - 2026-05-16 ### Added diff --git a/backend/app/main.py b/backend/app/main.py index a24fc5114..21a26923f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2121,6 +2121,14 @@ async def on_print_start(printer_id: int, data: dict): except Exception as e: logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e) + # Capture timelapse file baseline for snapshot-diff on completion + # (mirrors the new-archive branch). Queue / VP-dispatched prints + # hit this branch — without the baseline the completion-time scan + # falls into its "take baseline now" fallback, which snapshots + # AFTER the new MP4 already exists and never matches a diff + # (#1403 follow-up — see pwostran's 2026-05-18 support bundle). + await _capture_timelapse_baseline_at_start(printer, printer_id, logger) + return # Skip creating a new archive # Check if there's already a "printing" archive for this printer/file @@ -2725,16 +2733,7 @@ async def on_print_start(printer_id: int, data: dict): logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e) # Capture timelapse file baseline for snapshot-diff on completion - try: - baseline_files, _ = await _list_timelapse_videos(printer) - _timelapse_baselines[printer_id] = {f.get("name", "") for f in baseline_files} - logger.info( - "[TIMELAPSE] Baseline at print start: %s video files for printer %s", - len(_timelapse_baselines[printer_id]), - printer_id, - ) - except Exception as e: - logger.warning("[TIMELAPSE] Failed to capture baseline at print start: %s", e) + await _capture_timelapse_baseline_at_start(printer, printer_id, logger) finally: # Keep temp_path around until print completes so the cover endpoint # can reuse it (#972). Cache eviction in on_print_complete deletes @@ -2779,6 +2778,32 @@ async def _list_timelapse_videos(printer) -> tuple[list[dict], str | None]: return [], None +async def _capture_timelapse_baseline_at_start(printer, printer_id: int, logger: logging.Logger) -> None: + """Snapshot the printer's timelapse directory at print start so the + completion-time scan can pick the new file by set-difference. + + Must be called from every on_print_start path that proceeds to a real + print — both the new-archive branch and the expected-archive branch (which + queue / VP-dispatched prints take). Without a baseline, + _scan_for_timelapse_with_retries falls into its "take baseline now" + fallback that runs AFTER the new MP4 has already landed on the SD card, + so the new file ends up in the "baseline" set and no diff ever matches. + + Bambu printers in LAN-only mode don't sync NTP, so mtime ordering is + unreliable — the snapshot-diff approach sidesteps that entirely. + """ + try: + baseline_files, _ = await _list_timelapse_videos(printer) + _timelapse_baselines[printer_id] = {f.get("name", "") for f in baseline_files} + logger.info( + "[TIMELAPSE] Baseline at print start: %s video files for printer %s", + len(_timelapse_baselines[printer_id]), + printer_id, + ) + except Exception as e: + logger.warning("[TIMELAPSE] Failed to capture baseline at print start: %s", e) + + async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[str] | None = None): """ Scan for timelapse with retries using a snapshot-diff approach. diff --git a/backend/tests/unit/test_print_start_assigns_printer_id_to_vp_archive.py b/backend/tests/unit/test_print_start_assigns_printer_id_to_vp_archive.py index 54fa0dff8..cad34a80d 100644 --- a/backend/tests/unit/test_print_start_assigns_printer_id_to_vp_archive.py +++ b/backend/tests/unit/test_print_start_assigns_printer_id_to_vp_archive.py @@ -22,6 +22,7 @@ from backend.app.main import ( _expected_print_registered_at, _expected_prints, _print_ams_mappings, + _timelapse_baselines, register_expected_print, ) @@ -33,12 +34,14 @@ def _clear_dicts(): _expected_print_creators.clear() _print_ams_mappings.clear() _active_prints.clear() + _timelapse_baselines.clear() yield _expected_prints.clear() _expected_print_registered_at.clear() _expected_print_creators.clear() _print_ams_mappings.clear() _active_prints.clear() + _timelapse_baselines.clear() @pytest.mark.asyncio @@ -205,3 +208,107 @@ async def test_expected_archive_path_preserves_existing_printer_id(): assert mock_archive.printer_id == 7 assert mock_archive.status == "printing" + + +@pytest.mark.asyncio +async def test_expected_archive_path_captures_timelapse_baseline(): + """VP-queue prints hit the expected-archive branch, which used to skip the + timelapse baseline-capture step that the new-archive branch did. Without a + baseline, _scan_for_timelapse_with_retries falls into its "take baseline + now" fallback that snapshots the SD card AFTER the new MP4 has landed — + the new file ends up in the baseline set and no diff ever matches, so + auto-attach never picks the right file. + + Regression: at print start the global _timelapse_baselines dict must + contain the snapshot of existing video filenames for this printer_id, + so the completion-time scan can set-diff against it. + """ + mock_printer = MagicMock() + mock_printer.id = 1 + mock_printer.auto_archive = True + mock_printer.external_camera_enabled = False + mock_printer.external_camera_url = None + mock_printer.name = "TestP1S" + + mock_archive = MagicMock() + mock_archive.id = 42 + mock_archive.filename = "bambu_lab_a1_tool_plate_3.gcode.3mf" + mock_archive.subtask_id = None + mock_archive.print_time_seconds = None + mock_archive.created_by_id = None + mock_archive.printer_id = None + mock_archive.print_name = "A1 Tool Plate 3" + mock_archive.status = "archived" + mock_archive.file_path = "/tmp/fake.3mf" # nosec B108 — mock path; nothing ever writes to it + mock_archive.energy_start_kwh = None + + register_expected_print(1, "bambu_lab_a1_tool_plate_3.gcode.3mf", archive_id=42, ams_mapping=None) + + # Two pre-existing files on the printer's SD card before this print starts. + # The fake completion scan would diff against this set. + existing_videos = [ + {"name": "earlier_print_a.mp4", "is_directory": False, "path": "/timelapse/earlier_print_a.mp4"}, + {"name": "earlier_print_b.mp4", "is_directory": False, "path": "/timelapse/earlier_print_b.mp4"}, + ] + + def execute_router(stmt, *args, **kwargs): + sql = str(stmt).lower() + if "from printers" in sql or "from printer " in sql: + return MagicMock( + scalar_one_or_none=MagicMock(return_value=mock_printer), + scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_printer]))), + ) + if "from print_archives" in sql or "from print_archive" in sql: + return MagicMock( + scalar_one_or_none=MagicMock(return_value=mock_archive), + scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_archive]))), + ) + return MagicMock( + scalar_one_or_none=MagicMock(return_value=None), + scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))), + ) + + mock_session = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock() + mock_session.execute = AsyncMock(side_effect=execute_router) + mock_session.commit = AsyncMock() + + with ( + patch("backend.app.main.async_session") as mock_session_maker, + patch("backend.app.main.notification_service") as mock_notif, + patch("backend.app.main.smart_plug_manager") as mock_plug, + patch("backend.app.main.ws_manager") as mock_ws, + patch("backend.app.main.printer_manager") as mock_pm, + patch("backend.app.main.mqtt_relay") as mock_relay, + patch("backend.app.main._record_energy_start", new_callable=AsyncMock), + patch("backend.app.main._load_objects_from_archive"), + patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock), + patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock), + patch( + "backend.app.main._list_timelapse_videos", + new=AsyncMock(return_value=(existing_videos, "/timelapse")), + ), + ): + mock_session_maker.return_value = mock_session + mock_notif.on_print_start = AsyncMock() + mock_plug.on_print_start = AsyncMock() + mock_ws.send_print_start = AsyncMock() + mock_ws.send_archive_updated = AsyncMock() + mock_relay.on_print_start = AsyncMock() + mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123")) + + from backend.app.main import on_print_start + + await on_print_start( + 1, + { + "filename": "bambu_lab_a1_tool_plate_3.gcode.3mf", + "subtask_name": "bambu_lab_a1_tool_plate_3", + }, + ) + + assert _timelapse_baselines.get(1) == {"earlier_print_a.mp4", "earlier_print_b.mp4"}, ( + "expected-archive branch must capture the printer's existing-videos " + "baseline so completion-time scan can set-diff to find the new file" + )