mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
Bambu's end-gcode lowers the bed at gcode_state=FINISH. Bambuddy's live-camera grab captured the bed already dropped, ruining the photo framing. Source the photo from a brief Bambu timelapse instead — firmware stops timelapse recording AFTER toolhead parks but BEFORE bed-drop runs, so the last frame frames the finished print correctly. When capture_finish_photo is on AND the user did not opt in to timelapse for this print, force timelapse=True at dispatch + mark the new PrintArchive.bambuddy_forced_timelapse column. After extraction (success or failure), cleanup deletes the locally-attached file, clears archive.timelapse_path, and walks the four scanner directories (/timelapse, /timelapse/video, /record, /recording) trying FTP DELE against the original filename. User-opted-in timelapses pass through unchanged. Resolver lives at services/background_dispatch.py::resolve_effective_timelapse (module-level so the print queue can reuse it). Both dispatch paths wired: background_dispatch.py (Print Now / Reprint) AND print_scheduler.py:_start_print (the queue). Field testing caught the scheduler gap on the first round — AST regression test now asserts start_print(timelapse=...) references effective_timelapse, not the raw item.timelapse, so a future refactor can't silently drop it. Extractor: ffmpeg -i input.mp4 -update 1 -q:v 2 out.jpg. Decoded frames overwrite the same output file, so the file left on disk is the literal last frame regardless of duration. Bambu records one frame per layer-change, so a 16-layer cube produces a 0.6 s timelapse — the original -sseof -1.0 approach seeked before the start of the file and returned frame 0 (empty bed). Decoding every frame is fine; Bambu timelapses are short by construction even on hours-long prints. Migration adds bambuddy_forced_timelapse branched on is_sqlite() (DEFAULT 0 / DEFAULT FALSE — PG rejects DEFAULT 0 for BOOLEAN). Verified live on postgres:16-alpine. Photo-task wait_for budget extends 45s -> 75s when timelapse_was_active so the notification carries the bed-up photo instead of falling back to the live-cam grab on slow links. Scope limit, documented in the camera wiki: prints started directly on the printer touchscreen / Bambu Handy / Bambu Studio Send bypass both dispatch paths, so the override doesn't fire there. Future option: mid-print M981 S1 P20000 MQTT toggle in on_print_start. Setting description rewritten in all 11 locales to drop the "only works when timelapse enabled" caveat (Bambuddy now forces it) and explain the kept-or-deleted behaviour.
421 lines
15 KiB
Python
421 lines
15 KiB
Python
"""Unit tests for background dispatch service."""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from backend.app.services.background_dispatch import (
|
|
ActiveDispatchState,
|
|
BackgroundDispatchService,
|
|
DispatchEnqueueRejected,
|
|
PrintDispatchJob,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_rejects_when_printer_busy_printing():
|
|
"""Reject enqueue when target printer is already printing."""
|
|
service = BackgroundDispatchService()
|
|
|
|
with (
|
|
patch(
|
|
"backend.app.services.background_dispatch.printer_manager.get_status",
|
|
return_value=SimpleNamespace(state="RUNNING", gcode_file="active.gcode.3mf"),
|
|
),
|
|
pytest.raises(DispatchEnqueueRejected, match="currently busy printing"),
|
|
):
|
|
await service.dispatch_reprint_archive(
|
|
archive_id=1,
|
|
archive_name="Test Archive",
|
|
printer_id=10,
|
|
printer_name="Printer A",
|
|
options={},
|
|
requested_by_user_id=None,
|
|
requested_by_username=None,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_enqueues_job_and_broadcasts_state():
|
|
"""Enqueue succeeds and emits websocket queue update."""
|
|
service = BackgroundDispatchService()
|
|
|
|
with (
|
|
patch("backend.app.services.background_dispatch.printer_manager.get_status", return_value=None),
|
|
patch(
|
|
"backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
|
|
) as mock_broadcast,
|
|
):
|
|
result = await service.dispatch_print_library_file(
|
|
file_id=22,
|
|
filename="cube.gcode.3mf",
|
|
printer_id=7,
|
|
printer_name="Printer B",
|
|
options={"plate_id": 2},
|
|
requested_by_user_id=5,
|
|
requested_by_username="tester",
|
|
)
|
|
|
|
assert result["status"] == "dispatched"
|
|
assert result["dispatch_job_id"] == 1
|
|
assert result["dispatch_position"] == 1
|
|
assert len(service._queued_jobs) == 1
|
|
|
|
mock_broadcast.assert_awaited_once()
|
|
payload = mock_broadcast.await_args.args[0]
|
|
assert payload["type"] == "background_dispatch"
|
|
assert payload["data"]["recent_event"]["status"] == "dispatched"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_library_file_defaults_cleanup_flag_false():
|
|
"""cleanup_library_after_dispatch defaults to False when not passed — protects
|
|
File Manager / Project Detail / queued-library-file paths from surprise deletion."""
|
|
service = BackgroundDispatchService()
|
|
|
|
with (
|
|
patch("backend.app.services.background_dispatch.printer_manager.get_status", return_value=None),
|
|
patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock),
|
|
):
|
|
await service.dispatch_print_library_file(
|
|
file_id=1,
|
|
filename="cube.gcode.3mf",
|
|
printer_id=1,
|
|
printer_name="Printer A",
|
|
options={},
|
|
requested_by_user_id=None,
|
|
requested_by_username=None,
|
|
)
|
|
|
|
assert len(service._queued_jobs) == 1
|
|
assert service._queued_jobs[0].cleanup_library_after_dispatch is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_library_file_propagates_cleanup_flag_true():
|
|
"""cleanup_library_after_dispatch=True arrives on the queued job so the runner
|
|
can delete the transient LibraryFile after the print is accepted by the printer."""
|
|
service = BackgroundDispatchService()
|
|
|
|
with (
|
|
patch("backend.app.services.background_dispatch.printer_manager.get_status", return_value=None),
|
|
patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock),
|
|
):
|
|
await service.dispatch_print_library_file(
|
|
file_id=1,
|
|
filename="cube.gcode.3mf",
|
|
printer_id=1,
|
|
printer_name="Printer A",
|
|
options={},
|
|
requested_by_user_id=42,
|
|
requested_by_username="alice",
|
|
cleanup_library_after_dispatch=True,
|
|
)
|
|
|
|
assert len(service._queued_jobs) == 1
|
|
job = service._queued_jobs[0]
|
|
assert job.cleanup_library_after_dispatch is True
|
|
# Sanity: other fields still wired correctly
|
|
assert job.requested_by_user_id == 42
|
|
assert job.requested_by_username == "alice"
|
|
assert job.kind == "print_library_file"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_queued_job_removes_it_and_broadcasts():
|
|
"""Cancelling queued job removes it immediately."""
|
|
service = BackgroundDispatchService()
|
|
|
|
with (
|
|
patch("backend.app.services.background_dispatch.printer_manager.get_status", return_value=None),
|
|
patch(
|
|
"backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
|
|
) as mock_broadcast,
|
|
):
|
|
result = await service.dispatch_reprint_archive(
|
|
archive_id=1,
|
|
archive_name="benchy.gcode.3mf",
|
|
printer_id=1,
|
|
printer_name="Printer 1",
|
|
options={},
|
|
requested_by_user_id=None,
|
|
requested_by_username=None,
|
|
)
|
|
mock_broadcast.reset_mock()
|
|
|
|
cancel_result = await service.cancel_job(result["dispatch_job_id"])
|
|
|
|
assert cancel_result["cancelled"] is True
|
|
assert cancel_result["pending"] is False
|
|
assert len(service._queued_jobs) == 0
|
|
assert service._batch_total == 0
|
|
|
|
mock_broadcast.assert_awaited_once()
|
|
payload = mock_broadcast.await_args.args[0]
|
|
assert payload["data"]["recent_event"]["status"] == "cancelled"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_active_job_marks_pending_and_sets_cancel_flag():
|
|
"""Cancelling active job marks it as pending cancellation."""
|
|
service = BackgroundDispatchService()
|
|
job = PrintDispatchJob(
|
|
id=42,
|
|
kind="reprint_archive",
|
|
source_id=100,
|
|
source_name="gearbox.gcode.3mf",
|
|
printer_id=3,
|
|
printer_name="Printer C",
|
|
)
|
|
service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Uploading...")
|
|
|
|
with patch(
|
|
"backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
|
|
) as mock_broadcast:
|
|
result = await service.cancel_job(job.id)
|
|
|
|
assert result["cancelled"] is True
|
|
assert result["pending"] is True
|
|
assert job.id in service._cancel_requested_job_ids
|
|
|
|
mock_broadcast.assert_awaited_once()
|
|
payload = mock_broadcast.await_args.args[0]
|
|
assert payload["data"]["recent_event"]["status"] == "cancelling"
|
|
|
|
|
|
def test_resolve_plate_id_uses_request_value_when_provided(tmp_path):
|
|
"""Explicit plate_id wins over auto-detection."""
|
|
file_path = tmp_path / "dummy.3mf"
|
|
file_path.write_text("not-a-zip")
|
|
|
|
plate_id = BackgroundDispatchService._resolve_plate_id(file_path, requested_plate_id=9)
|
|
assert plate_id == 9
|
|
|
|
|
|
def test_resolve_plate_id_auto_detects_from_3mf(tmp_path):
|
|
"""Auto-detect plate from Metadata/plate_X.gcode entry."""
|
|
import zipfile
|
|
|
|
file_path = tmp_path / "multi.3mf"
|
|
with zipfile.ZipFile(file_path, "w") as zf:
|
|
zf.writestr("Metadata/plate_7.gcode", b"G1 X0 Y0")
|
|
|
|
plate_id = BackgroundDispatchService._resolve_plate_id(file_path, requested_plate_id=None)
|
|
assert plate_id == 7
|
|
|
|
|
|
def test_is_sliced_file_recognizes_supported_extensions():
|
|
"""Only .gcode and .gcode.3mf should be accepted."""
|
|
assert BackgroundDispatchService._is_sliced_file("part.gcode") is True
|
|
assert BackgroundDispatchService._is_sliced_file("part.gcode.3mf") is True
|
|
assert BackgroundDispatchService._is_sliced_file("part.3mf") is False
|
|
|
|
|
|
def test_dispatch_option_defaults_align_with_request_schema_defaults():
|
|
"""The `job.options.get("<field>", <default>)` calls in the dispatch
|
|
loop must use the same default as the Pydantic request schema. If a
|
|
field is missing from options (e.g. an internal caller bypassing the
|
|
schema), the resulting print command must match what a fresh
|
|
`ReprintRequest()` / `FilePrintRequest()` would produce — anything
|
|
else means certain fields silently flip depending on which entry
|
|
point queued the job.
|
|
|
|
Earlier `vibration_cali` had a False default in the dispatch loop
|
|
against a True schema default, latent only because every existing
|
|
caller always sent the field.
|
|
"""
|
|
import inspect
|
|
|
|
from backend.app.schemas.archive import ReprintRequest
|
|
from backend.app.schemas.library import FilePrintRequest
|
|
from backend.app.services import background_dispatch as bd
|
|
|
|
# `timelapse` deliberately excluded — the dispatcher now resolves it via
|
|
# ``_resolve_effective_timelapse`` so the value passed to ``start_print``
|
|
# depends on the ``capture_finish_photo`` setting + ``bambuddy_forced_timelapse``
|
|
# column (#1397). The original literal `job.options.get("timelapse", False)`
|
|
# pattern no longer appears.
|
|
fields = ("bed_levelling", "flow_cali", "vibration_cali", "layer_inspect", "use_ams")
|
|
reprint_defaults = {f: getattr(ReprintRequest(), f) for f in fields}
|
|
libprint_defaults = {f: getattr(FilePrintRequest(), f) for f in fields}
|
|
assert reprint_defaults == libprint_defaults, (
|
|
"ReprintRequest and FilePrintRequest must share the same defaults for these fields"
|
|
)
|
|
|
|
src = inspect.getsource(bd)
|
|
for field, expected_default in reprint_defaults.items():
|
|
literal = "True" if expected_default else "False"
|
|
needle = f'{field}=job.options.get("{field}", {literal})'
|
|
count = src.count(needle)
|
|
assert count == 2, (
|
|
f"Expected exactly 2 occurrences of `{needle}` in background_dispatch (one per "
|
|
f"`_process_job` branch). Found {count}. A drift between the request schema's "
|
|
f"default for `{field}` and the dispatch loop's `.get()` default means callers "
|
|
f"that bypass the schema will get inconsistent behaviour."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_job_not_found_returns_false():
|
|
"""Cancelling a nonexistent job returns not_found."""
|
|
service = BackgroundDispatchService()
|
|
|
|
with patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock):
|
|
result = await service.cancel_job(999)
|
|
|
|
assert result["cancelled"] is False
|
|
assert result["reason"] == "not_found"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_job_single_lock_covers_both_active_and_queued():
|
|
"""cancel_job checks both active and queued jobs under a single lock acquisition.
|
|
|
|
Regression test for TOCTOU race: previously two separate lock acquisitions allowed
|
|
the dispatcher loop to move a job from queue to active between them, causing cancel
|
|
to find it in neither place.
|
|
"""
|
|
service = BackgroundDispatchService()
|
|
|
|
# Set up a job in the queue AND an active job for a different printer
|
|
active_job = PrintDispatchJob(
|
|
id=1,
|
|
kind="reprint_archive",
|
|
source_id=10,
|
|
source_name="active.3mf",
|
|
printer_id=1,
|
|
printer_name="Printer 1",
|
|
)
|
|
service._active_jobs[active_job.id] = ActiveDispatchState(job=active_job, message="Uploading...")
|
|
|
|
queued_job = PrintDispatchJob(
|
|
id=2,
|
|
kind="reprint_archive",
|
|
source_id=20,
|
|
source_name="queued.3mf",
|
|
printer_id=2,
|
|
printer_name="Printer 2",
|
|
)
|
|
service._queued_jobs.append(queued_job)
|
|
service._batch_total = 2
|
|
|
|
with patch(
|
|
"backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
|
|
) as mock_broadcast:
|
|
# Cancel the queued job — should find it in single lock acquisition
|
|
result = await service.cancel_job(2)
|
|
|
|
assert result["cancelled"] is True
|
|
assert result["pending"] is False
|
|
assert len(service._queued_jobs) == 0
|
|
# Active job should be untouched
|
|
assert 1 in service._active_jobs
|
|
|
|
mock_broadcast.assert_awaited_once()
|
|
payload = mock_broadcast.await_args.args[0]
|
|
assert payload["data"]["recent_event"]["status"] == "cancelled"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mark_job_finished_resets_batch_when_all_done():
|
|
"""Batch counters reset after last job completes."""
|
|
service = BackgroundDispatchService()
|
|
job = PrintDispatchJob(
|
|
id=1,
|
|
kind="reprint_archive",
|
|
source_id=10,
|
|
source_name="test.3mf",
|
|
printer_id=1,
|
|
printer_name="Printer 1",
|
|
)
|
|
service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Done")
|
|
service._batch_total = 1
|
|
|
|
with patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock):
|
|
await service._mark_job_finished(job, failed=False, message="Complete")
|
|
|
|
assert service._batch_total == 0
|
|
assert service._batch_completed == 0
|
|
assert service._batch_failed == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mark_job_finished_no_reset_when_jobs_remain():
|
|
"""Batch counters NOT reset when queued jobs remain."""
|
|
service = BackgroundDispatchService()
|
|
job = PrintDispatchJob(
|
|
id=1,
|
|
kind="reprint_archive",
|
|
source_id=10,
|
|
source_name="test.3mf",
|
|
printer_id=1,
|
|
printer_name="Printer 1",
|
|
)
|
|
remaining_job = PrintDispatchJob(
|
|
id=2,
|
|
kind="reprint_archive",
|
|
source_id=20,
|
|
source_name="next.3mf",
|
|
printer_id=2,
|
|
printer_name="Printer 2",
|
|
)
|
|
service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Done")
|
|
service._queued_jobs.append(remaining_job)
|
|
service._batch_total = 2
|
|
|
|
with patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock):
|
|
await service._mark_job_finished(job, failed=False, message="Complete")
|
|
|
|
# Batch counters should NOT be reset — remaining job still queued
|
|
assert service._batch_total == 2
|
|
assert service._batch_completed == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mark_job_finished_batch_reset_rechecks_under_lock():
|
|
"""Batch reset re-checks condition inside second lock acquisition.
|
|
|
|
Regression test for TOCTOU: a new dispatch between the two lock acquisitions
|
|
could get its counters zeroed if the re-check is missing.
|
|
"""
|
|
service = BackgroundDispatchService()
|
|
job = PrintDispatchJob(
|
|
id=1,
|
|
kind="reprint_archive",
|
|
source_id=10,
|
|
source_name="test.3mf",
|
|
printer_id=1,
|
|
printer_name="Printer 1",
|
|
)
|
|
service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Done")
|
|
service._batch_total = 1
|
|
|
|
original_broadcast = AsyncMock()
|
|
|
|
async def inject_new_job_during_broadcast(msg):
|
|
"""Simulate a new dispatch arriving between the two lock acquisitions."""
|
|
await original_broadcast(msg)
|
|
# After broadcast (lock released), inject a new job before reset re-check
|
|
if not service._queued_jobs:
|
|
new_job = PrintDispatchJob(
|
|
id=99,
|
|
kind="reprint_archive",
|
|
source_id=99,
|
|
source_name="injected.3mf",
|
|
printer_id=5,
|
|
printer_name="Printer 5",
|
|
)
|
|
service._queued_jobs.append(new_job)
|
|
service._batch_total = 1
|
|
|
|
with patch(
|
|
"backend.app.services.background_dispatch.ws_manager.broadcast",
|
|
side_effect=inject_new_job_during_broadcast,
|
|
):
|
|
await service._mark_job_finished(job, failed=False, message="Complete")
|
|
|
|
# Re-check should prevent reset since a new job appeared
|
|
assert service._batch_total == 1
|
|
assert len(service._queued_jobs) == 1
|