| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510 |
- """A print-start baseline taken off a card that could not be read (#3412).
- The capture at print start knew the card was unreadable -- it logged so -- and
- then recorded the empty listing exactly like a card that held nothing, both in
- memory and on the archive row. The completion scan believed either copy, so
- with two old videos still on the printer it attached the first of them to this
- print, deleted it off the printer and took the finish photo from it. The
- ``require_unambiguous`` guard from #2957 only covered a baseline the scan took
- itself at completion.
- Now the baseline carries whether the card was read; an unread one keeps the
- scan from choosing between several videos, and is read again while the print
- runs, since the printer writes the video only once the print has ended.
- """
- from __future__ import annotations
- import asyncio
- import logging
- import time
- from datetime import datetime
- from types import SimpleNamespace
- from unittest.mock import AsyncMock, patch
- import pytest
- from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
- from backend.app.models.archive import PrintArchive
- from backend.app.models.printer import Printer
- from backend.app.services import bambu_ftp
- from backend.app.services.bambu_ftp import FileListResult
- pytestmark = pytest.mark.asyncio
- PRINTER_IP = "172.25.12.150"
- # Two videos of earlier prints that nothing claimed, as on the reporter's X2D.
- OLD_A = "video_2026-08-22_20-19-02_4.mp4"
- OLD_B = "video_2026-08-23_09-00-00.mp4"
- NEW_VIDEO = "video_2026-10-24_14-00-00.mp4"
- STARTED = datetime(2026, 10, 24, 11, 5, 1)
- def _entry(name: str) -> dict:
- return {"name": name, "size": 1024, "is_directory": False, "path": f"/timelapse/{name}"}
- @pytest.fixture(autouse=True)
- def _forget_baselines():
- from backend.app import main
- yield
- main._timelapse_baselines.clear()
- main._timelapse_baselines_unread.clear()
- async def _seed(engine, *, baseline: list[str] | None = None, unread: bool = False):
- maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
- async with maker() as db:
- printer = Printer(
- name="X2D", serial_number="20P9AJ5A0700123", ip_address=PRINTER_IP, access_code="12345678", model="X2D"
- )
- db.add(printer)
- await db.commit()
- await db.refresh(printer)
- archive = PrintArchive(
- printer_id=printer.id,
- filename="Bracket.gcode.3mf",
- file_path="",
- file_size=0,
- print_name="Bracket",
- status="printing",
- started_at=STARTED,
- timelapse_baseline=baseline,
- timelapse_baseline_unread=unread,
- )
- db.add(archive)
- await db.commit()
- await db.refresh(archive)
- return maker, printer, archive.id
- def _ftp(main_module, maker, monkeypatch, tmp_path, listing, *, state="RUNNING", others=None):
- """Stand in for the FTP layer and the printer's live status.
- ``listing()`` is what ``/timelapse`` holds at that moment, or None while
- the card cannot be read -- what both listing calls then answer. The other
- candidate directories answer "no such directory" unless ``others`` gives
- them a FileListResult of their own.
- """
- from backend.app.core.config import settings as app_config
- from backend.app.services import bambu_ftp
- monkeypatch.setattr(app_config, "base_dir", tmp_path)
- monkeypatch.setattr(app_config, "archive_dir", tmp_path / "archive")
- monkeypatch.setattr(main_module, "_TIMELAPSE_SCAN_FIRST_DELAY_SECONDS", 0.01)
- monkeypatch.setattr(main_module, "_TIMELAPSE_SCAN_POLL_INTERVAL_SECONDS", 0.01)
- monkeypatch.setattr(main_module, "_TIMELAPSE_SCAN_TIMEOUT_SECONDS", 0.3)
- monkeypatch.setattr(main_module, "_TIMELAPSE_BASELINE_REREAD_INTERVAL_SECONDS", 0.01)
- monkeypatch.setattr(main_module, "_TIMELAPSE_BASELINE_REREAD_MAX_ATTEMPTS", 20)
- async def _list(ip, code, path, printer_model=None):
- return (listing() or []) if path == "/timelapse" else []
- async def _list_result(ip, code, path, timeout=30.0, socket_timeout=None, printer_model=None):
- if path != "/timelapse":
- return (others or {}).get(path, FileListResult(files=[], available=False, missing=True))
- files = listing()
- return FileListResult(files=files or [], available=files is not None)
- status = SimpleNamespace(connected=True, state=state, sdcard=True, sdcard_reported=True)
- monkeypatch.setattr(bambu_ftp, "list_files_async", _list)
- monkeypatch.setattr(bambu_ftp, "list_files_result_async", _list_result)
- monkeypatch.setattr(bambu_ftp, "download_file_bytes_async", AsyncMock(return_value=b"x" * 1024))
- monkeypatch.setattr(bambu_ftp, "remote_file_settled", AsyncMock(return_value=True))
- monkeypatch.setattr(bambu_ftp, "delete_archived_timelapse", AsyncMock(return_value=True))
- monkeypatch.setattr(main_module.printer_manager, "get_status", lambda _pid: status)
- return status, patch.object(main_module, "async_session", maker)
- async def _complete(main, archive_id, printer_id):
- """What on_print_complete does: schedule the scan, here run inline."""
- spawned = []
- with patch.object(main, "spawn_background_task", lambda coro, name=None: spawned.append(coro)):
- main._schedule_timelapse_scan(archive_id, printer_id, plate_id=None)
- assert len(spawned) == 1
- await spawned[0]
- async def _attached(maker, archive_id) -> str | None:
- async with maker() as db:
- return (await db.get(PrintArchive, archive_id)).timelapse_path
- class TestTheScanDoesNotGuess:
- """The reporter's chain, from the stored baseline on: [] at print start, and
- by completion the card readable again with this print's video beside two
- old ones."""
- async def test_a_stored_unread_baseline_leaves_several_videos_alone(self, test_engine, tmp_path, monkeypatch):
- from backend.app import main
- maker, _, archive_id = await _seed(test_engine, baseline=[], unread=True)
- _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [_entry(OLD_A), _entry(OLD_B), _entry(NEW_VIDEO)])
- with session:
- await main._scan_for_timelapse_with_retries(archive_id, None)
- assert await _attached(maker, archive_id) is None, "an unread baseline must not pick between three videos"
- async def test_an_unread_baseline_handed_over_in_memory_leaves_them_alone(self, test_engine, tmp_path, monkeypatch):
- """The in-process path: on_print_complete hands the in-memory copy over,
- and the archive row is never consulted."""
- from backend.app import main
- maker, printer, archive_id = await _seed(test_engine)
- _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [_entry(OLD_A), _entry(OLD_B), _entry(NEW_VIDEO)])
- main._timelapse_baselines[printer.id] = set()
- main._timelapse_baselines_unread.add(printer.id)
- with session:
- await _complete(main, archive_id, printer.id)
- assert printer.id not in main._timelapse_baselines
- assert printer.id not in main._timelapse_baselines_unread
- assert await _attached(maker, archive_id) is None
- async def test_a_stored_read_baseline_beats_an_unread_one_taken_after_a_restart(
- self, test_engine, tmp_path, monkeypatch
- ):
- """Read at print start; after a restart the running-observed capture
- could not read the card. The stored one is this run's."""
- from backend.app import main
- maker, printer, archive_id = await _seed(test_engine, baseline=[OLD_A, OLD_B], unread=False)
- _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [_entry(OLD_A), _entry(OLD_B), _entry(NEW_VIDEO)])
- main._timelapse_baselines[printer.id] = set()
- main._timelapse_baselines_unread.add(printer.id)
- with session:
- await _complete(main, archive_id, printer.id)
- attached = await _attached(maker, archive_id)
- assert attached is not None and NEW_VIDEO in attached
- async def test_a_lone_video_still_resolves_off_an_unread_baseline(self, test_engine, tmp_path, monkeypatch):
- """The usual card: Bambuddy deletes each video once attached, so only
- this print's is there, and one candidate needs no baseline."""
- from backend.app import main
- maker, _, archive_id = await _seed(test_engine, baseline=[], unread=True)
- _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [_entry(NEW_VIDEO)])
- with session:
- await main._scan_for_timelapse_with_retries(archive_id, None)
- attached = await _attached(maker, archive_id)
- assert attached is not None and NEW_VIDEO in attached
- async def test_a_read_baseline_still_picks_the_new_video(self, test_engine, tmp_path, monkeypatch):
- from backend.app import main
- maker, _, archive_id = await _seed(test_engine, baseline=[OLD_A, OLD_B], unread=False)
- _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [_entry(OLD_A), _entry(OLD_B), _entry(NEW_VIDEO)])
- with session:
- await main._scan_for_timelapse_with_retries(archive_id, None)
- attached = await _attached(maker, archive_id)
- assert attached is not None and NEW_VIDEO in attached
- async def test_a_baseline_the_scan_takes_itself_off_a_failed_listing(self, test_engine, tmp_path, monkeypatch):
- """No cool-off running, the listing simply failed (a timeout, a 421 on
- this very connection): the old check only knew about the cool-off."""
- from backend.app import main
- maker, _, archive_id = await _seed(test_engine)
- rounds = iter([None, None]) # both baseline listings fail, the poll's succeed
- _, session = _ftp(
- main,
- maker,
- monkeypatch,
- tmp_path,
- lambda: next(rounds, [_entry(OLD_A), _entry(OLD_B), _entry(NEW_VIDEO)]),
- )
- with session:
- await main._scan_for_timelapse_with_retries(archive_id, None)
- assert await _attached(maker, archive_id) is None
- class TestTheCaptureAtPrintStart:
- async def _capture(self, main, printer, archive_id):
- spawned = []
- def _spawn(coro, name=None):
- spawned.append(name)
- coro.close()
- with patch.object(main, "spawn_background_task", _spawn):
- await main._capture_timelapse_baseline_at_start(
- printer, printer.id, logging.getLogger(__name__), archive_id=archive_id
- )
- return spawned
- async def test_an_unreadable_card_is_recorded_as_unread(self, test_engine, tmp_path, monkeypatch):
- from backend.app import main
- maker, printer, archive_id = await _seed(test_engine)
- _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: None)
- with session:
- spawned = await self._capture(main, printer, archive_id)
- async with maker() as db:
- archive = await db.get(PrintArchive, archive_id)
- assert (archive.timelapse_baseline, archive.timelapse_baseline_unread) == ([], True)
- assert main._timelapse_baselines[printer.id] == set()
- assert printer.id in main._timelapse_baselines_unread
- assert spawned == [f"reread-timelapse-baseline-{printer.id}"]
- async def test_a_card_in_cooloff_is_recorded_as_unread(self, test_engine, tmp_path, monkeypatch):
- """The reporter's log line: the cool-off was running at print start."""
- from backend.app import main
- from backend.app.services.bambu_ftp import BambuFTPClient
- maker, printer, archive_id = await _seed(test_engine)
- # A listing would find videos -- it must not be asked while blocked.
- _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [])
- BambuFTPClient._handshake_blocked_until[PRINTER_IP] = time.monotonic() + 60
- try:
- with session:
- await self._capture(main, printer, archive_id)
- finally:
- BambuFTPClient._handshake_blocked_until.pop(PRINTER_IP, None)
- async with maker() as db:
- assert (await db.get(PrintArchive, archive_id)).timelapse_baseline_unread is True
- async def test_an_empty_card_that_answered_is_read(self, test_engine, tmp_path, monkeypatch):
- from backend.app import main
- maker, printer, archive_id = await _seed(test_engine)
- _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [])
- with session:
- spawned = await self._capture(main, printer, archive_id)
- async with maker() as db:
- archive = await db.get(PrintArchive, archive_id)
- assert (archive.timelapse_baseline, archive.timelapse_baseline_unread) == ([], False)
- assert printer.id not in main._timelapse_baselines_unread
- assert spawned == []
- async def test_a_failed_directory_is_not_outvoted_by_one_that_answered(self, test_engine, tmp_path, monkeypatch):
- """/timelapse answers empty, /timelapse/video -- where this card keeps
- its videos -- fails: the card was not read."""
- from backend.app import main
- maker, printer, archive_id = await _seed(test_engine)
- failed = {"/timelapse/video": FileListResult(files=[], available=False)}
- _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [], others=failed)
- with session:
- await self._capture(main, printer, archive_id)
- async with maker() as db:
- assert (await db.get(PrintArchive, archive_id)).timelapse_baseline_unread is True
- async def test_a_card_without_any_timelapse_directory_is_read(self, test_engine, tmp_path, monkeypatch):
- """A fresh card: every directory answers 550. That is an answer, and a
- re-read could only get the same one."""
- from backend.app import main
- maker, printer, archive_id = await _seed(test_engine)
- _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [])
- monkeypatch.setattr(
- bambu_ftp,
- "list_files_result_async",
- AsyncMock(return_value=FileListResult(files=[], available=False, missing=True)),
- )
- with session:
- spawned = await self._capture(main, printer, archive_id)
- async with maker() as db:
- assert (await db.get(PrintArchive, archive_id)).timelapse_baseline_unread is False
- assert spawned == []
- async def test_an_empty_first_listing_is_replaced_by_the_one_that_answered(
- self, test_engine, tmp_path, monkeypatch
- ):
- """The first listing fails, the second reads the card: its names are the
- baseline, not the empty set the first one returned."""
- from backend.app import main
- maker, printer, archive_id = await _seed(test_engine)
- rounds = iter([None])
- _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: next(rounds, [_entry(OLD_A), _entry(OLD_B)]))
- with session:
- await self._capture(main, printer, archive_id)
- async with maker() as db:
- archive = await db.get(PrintArchive, archive_id)
- assert (archive.timelapse_baseline, archive.timelapse_baseline_unread) == ([OLD_A, OLD_B], False)
- class TestTheCardIsReadAgainDuringThePrint:
- async def _start(self, main, printer):
- names: set[str] = set()
- main._timelapse_baselines[printer.id] = names
- main._timelapse_baselines_unread.add(printer.id)
- return names
- async def test_a_later_reading_becomes_the_baseline(self, test_engine, tmp_path, monkeypatch):
- """And with it the scan picks this print's video out of three."""
- from backend.app import main
- maker, printer, archive_id = await _seed(test_engine, baseline=[], unread=True)
- card: list[list[dict] | None] = [None]
- _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: card[0])
- names = await self._start(main, printer)
- with session:
- task = asyncio.create_task(main._reread_unread_timelapse_baseline(printer.id, names, archive_id, STARTED))
- await asyncio.sleep(0.05)
- assert printer.id in main._timelapse_baselines_unread, "nothing to record while the card is unreadable"
- card[0] = [_entry(OLD_A), _entry(OLD_B)] # the cool-off ends
- await asyncio.wait_for(task, 2)
- assert main._timelapse_baselines[printer.id] is names
- assert names == {OLD_A, OLD_B}
- assert printer.id not in main._timelapse_baselines_unread
- async with maker() as db:
- archive = await db.get(PrintArchive, archive_id)
- assert (archive.timelapse_baseline, archive.timelapse_baseline_unread) == ([OLD_A, OLD_B], False)
- card[0] = [_entry(OLD_A), _entry(OLD_B), _entry(NEW_VIDEO)] # the print ends
- await _complete(main, archive_id, printer.id)
- attached = await _attached(maker, archive_id)
- assert attached is not None and NEW_VIDEO in attached
- async def test_a_disconnect_skips_a_round_and_does_not_end_the_rereading(self, test_engine, tmp_path, monkeypatch):
- from backend.app import main
- maker, printer, archive_id = await _seed(test_engine, baseline=[], unread=True)
- status, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [_entry(OLD_A)])
- status.connected = False
- names = await self._start(main, printer)
- with session:
- task = asyncio.create_task(main._reread_unread_timelapse_baseline(printer.id, names, archive_id, STARTED))
- await asyncio.sleep(0.05)
- assert not task.done(), "a dropped connection says nothing about the print"
- assert names == set()
- status.connected = True
- await asyncio.wait_for(task, 2)
- assert names == {OLD_A}
- async def test_a_reading_that_returns_to_a_disconnected_printer_is_not_kept(
- self, test_engine, tmp_path, monkeypatch
- ):
- """The printer dropped off while the listing was out, so nothing says
- the print had not ended meanwhile."""
- from backend.app import main
- maker, printer, archive_id = await _seed(test_engine, baseline=[], unread=True)
- names = await self._start(main, printer)
- status = None
- card = [[_entry(OLD_A), _entry(NEW_VIDEO)]]
- def listing():
- if status.connected:
- status.connected = False # drops while this listing is out
- return card[0]
- return None
- status, session = _ftp(main, maker, monkeypatch, tmp_path, listing)
- with session:
- task = asyncio.create_task(main._reread_unread_timelapse_baseline(printer.id, names, archive_id, STARTED))
- await asyncio.sleep(0.05)
- task.cancel()
- assert names == set()
- assert printer.id in main._timelapse_baselines_unread
- async def test_nothing_is_read_once_the_print_has_ended(self, test_engine, tmp_path, monkeypatch):
- """After the end the printer may already be writing this print's video,
- which a reading would put into the baseline."""
- from backend.app import main
- maker, printer, archive_id = await _seed(test_engine, baseline=[], unread=True)
- _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [_entry(NEW_VIDEO)], state="FINISH")
- names = await self._start(main, printer)
- with session:
- await asyncio.wait_for(main._reread_unread_timelapse_baseline(printer.id, names, archive_id, STARTED), 2)
- assert names == set()
- async with maker() as db:
- assert (await db.get(PrintArchive, archive_id)).timelapse_baseline_unread is True
- async def test_a_reading_that_returns_after_completion_is_dropped(self, test_engine, tmp_path, monkeypatch):
- """The print ends while the listing is out: completion has taken the
- baseline by the time it comes back."""
- from backend.app import main
- maker, printer, archive_id = await _seed(test_engine, baseline=[], unread=True)
- names = await self._start(main, printer)
- status = None
- def listing():
- status.state = "FINISH"
- main._timelapse_baselines.pop(printer.id)
- main._timelapse_baselines_unread.discard(printer.id)
- return [_entry(OLD_A), _entry(NEW_VIDEO)]
- status, session = _ftp(main, maker, monkeypatch, tmp_path, listing)
- with session:
- await asyncio.wait_for(main._reread_unread_timelapse_baseline(printer.id, names, archive_id, STARTED), 2)
- assert names == set()
- async with maker() as db:
- assert (await db.get(PrintArchive, archive_id)).timelapse_baseline == []
- async def test_a_reading_still_in_the_print_but_with_the_baseline_gone_is_dropped(
- self, test_engine, tmp_path, monkeypatch
- ):
- """State alone is not the run: the next print can already be running."""
- from backend.app import main
- maker, printer, archive_id = await _seed(test_engine, baseline=[], unread=True)
- names = await self._start(main, printer)
- def listing():
- main._timelapse_baselines[printer.id] = {"the next print's"}
- return [_entry(OLD_A)]
- _, session = _ftp(main, maker, monkeypatch, tmp_path, listing)
- with session:
- await asyncio.wait_for(main._reread_unread_timelapse_baseline(printer.id, names, archive_id, STARTED), 2)
- assert names == set()
- async with maker() as db:
- assert (await db.get(PrintArchive, archive_id)).timelapse_baseline_unread is True
- async def test_a_reprinted_row_keeps_its_own_baseline(self, test_engine, tmp_path, monkeypatch):
- """The row passed to a newer run meanwhile: the reading belongs to the
- old run, so memory takes it and the row does not."""
- from backend.app import main
- maker, printer, archive_id = await _seed(test_engine, baseline=[], unread=True)
- _, session = _ftp(main, maker, monkeypatch, tmp_path, lambda: [_entry(OLD_A)])
- names = await self._start(main, printer)
- with session:
- await asyncio.wait_for(
- main._reread_unread_timelapse_baseline(printer.id, names, archive_id, datetime(2026, 10, 1)), 2
- )
- assert names == {OLD_A}
- async with maker() as db:
- archive = await db.get(PrintArchive, archive_id)
- assert (archive.timelapse_baseline, archive.timelapse_baseline_unread) == ([], True)
|