|
@@ -0,0 +1,297 @@
|
|
|
|
|
+"""External-camera captures must reuse the live view's frame (#2707).
|
|
|
|
|
+
|
|
|
|
|
+A USB camera allows exactly one V4L2 handle, so a one-shot capture taken while
|
|
|
|
|
+somebody is watching the live view doesn't degrade — it fails. The reporter
|
|
|
|
|
+measured 0 of 87 and 0 of 105 layer-timelapse captures on prints watched from
|
|
|
|
|
+start to finish, and finish-photo notifications going out with no image.
|
|
|
|
|
+
|
|
|
|
|
+The guards for the built-in camera (#1348, #1271) were never extended to the
|
|
|
|
|
+external paths, and the deeper reason they couldn't be: ``_last_frames`` was
|
|
|
|
|
+only ever populated by the built-in paths. ``generate_mjpeg_stream`` yields
|
|
|
|
|
+multipart-wrapped chunks, so the route layer had no way to recover the JPEG —
|
|
|
|
|
+hence the ``on_frame`` callback, and hence a guard alone would have found an
|
|
|
|
|
+empty buffer and skipped every time.
|
|
|
|
|
+
|
|
|
|
|
+These tests cover the plumbing (raw frames reach the callback) and each consumer
|
|
|
|
|
+that used to compete: layer timelapse, Obico polling, and plate detection.
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
+
|
|
|
|
|
+import pytest
|
|
|
|
|
+
|
|
|
|
|
+from backend.app.api.routes import camera
|
|
|
|
|
+from backend.app.services import external_camera, layer_timelapse
|
|
|
|
|
+from backend.app.services.obico_detection import ObicoDetectionService
|
|
|
|
|
+
|
|
|
|
|
+pytestmark = pytest.mark.asyncio
|
|
|
|
|
+
|
|
|
|
|
+LIVE_FRAME = b"\xff\xd8live-viewer-frame\xff\xd9"
|
|
|
|
|
+FRESH_FRAME = b"\xff\xd8fresh-capture\xff\xd9"
|
|
|
|
|
+PRINTER_ID = 9310
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.fixture(autouse=True)
|
|
|
|
|
+def _clean_registries():
|
|
|
|
|
+ def _purge():
|
|
|
|
|
+ for sid in [k for k in camera._active_streams if k.startswith(f"{PRINTER_ID}-")]:
|
|
|
|
|
+ camera._active_streams.pop(sid, None)
|
|
|
|
|
+ camera._last_frames.pop(PRINTER_ID, None)
|
|
|
|
|
+ camera._last_frame_times.pop(PRINTER_ID, None)
|
|
|
|
|
+ camera._stream_start_times.pop(PRINTER_ID, None)
|
|
|
|
|
+
|
|
|
|
|
+ _purge()
|
|
|
|
|
+ yield
|
|
|
|
|
+ _purge()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _attach_viewer(frame: bytes | None = LIVE_FRAME) -> None:
|
|
|
|
|
+ """Register a live external stream, as the stream route does."""
|
|
|
|
|
+ camera._active_streams[f"{PRINTER_ID}-ext-deadbeef"] = object()
|
|
|
|
|
+ if frame is not None:
|
|
|
|
|
+ camera._last_frames[PRINTER_ID] = frame
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# live_frame_for_capture — the shared decision
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_no_viewer_means_capture_normally():
|
|
|
|
|
+ defer, frame = camera.live_frame_for_capture(PRINTER_ID)
|
|
|
|
|
+
|
|
|
|
|
+ assert defer is False
|
|
|
|
|
+ assert frame is None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_viewer_with_a_buffered_frame_is_reused():
|
|
|
|
|
+ _attach_viewer()
|
|
|
|
|
+
|
|
|
|
|
+ defer, frame = camera.live_frame_for_capture(PRINTER_ID)
|
|
|
|
|
+
|
|
|
|
|
+ assert defer is True
|
|
|
|
|
+ assert frame == LIVE_FRAME
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_viewer_with_an_empty_buffer_means_skip_not_capture():
|
|
|
|
|
+ """#1348: competing for the device is worse than missing one frame."""
|
|
|
|
|
+ _attach_viewer(frame=None)
|
|
|
|
|
+
|
|
|
|
|
+ defer, frame = camera.live_frame_for_capture(PRINTER_ID)
|
|
|
|
|
+
|
|
|
|
|
+ assert defer is True
|
|
|
|
|
+ assert frame is None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# on_frame plumbing — without this the buffer is always empty
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_on_frame_receives_the_raw_jpeg_not_the_multipart_chunk():
|
|
|
|
|
+ """The consumers want a JPEG; the stream yields multipart. Hence a callback."""
|
|
|
|
|
+ captured: list[bytes] = []
|
|
|
|
|
+
|
|
|
|
|
+ async def _fake_usb(_url, _fps, on_process=None):
|
|
|
|
|
+ yield FRESH_FRAME
|
|
|
|
|
+
|
|
|
|
|
+ with patch.object(external_camera, "_stream_usb", _fake_usb):
|
|
|
|
|
+ chunks = [
|
|
|
|
|
+ chunk
|
|
|
|
|
+ async for chunk in external_camera.generate_mjpeg_stream(
|
|
|
|
|
+ "/dev/video0", "usb", fps=15, on_frame=captured.append
|
|
|
|
|
+ )
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ assert captured == [FRESH_FRAME], "callback did not get the raw frame"
|
|
|
|
|
+ assert b"--frame" in chunks[0], "wire format should still be multipart"
|
|
|
|
|
+ assert b"--frame" not in captured[0]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_a_raising_on_frame_callback_cannot_break_the_stream():
|
|
|
|
|
+ """Buffering is a side effect; it must never take the live view down."""
|
|
|
|
|
+
|
|
|
|
|
+ async def _fake_usb(_url, _fps, on_process=None):
|
|
|
|
|
+ yield FRESH_FRAME
|
|
|
|
|
+ yield FRESH_FRAME
|
|
|
|
|
+
|
|
|
|
|
+ def _boom(_frame: bytes) -> None:
|
|
|
|
|
+ raise RuntimeError("buffering blew up")
|
|
|
|
|
+
|
|
|
|
|
+ with patch.object(external_camera, "_stream_usb", _fake_usb):
|
|
|
|
|
+ chunks = [
|
|
|
|
|
+ chunk async for chunk in external_camera.generate_mjpeg_stream("/dev/video0", "usb", fps=15, on_frame=_boom)
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ assert len(chunks) == 2, "stream stopped because the callback raised"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# Layer timelapse — the 0-of-87 case
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _session(tmp_path) -> layer_timelapse.TimelapseSession:
|
|
|
|
|
+ with patch.object(layer_timelapse.settings, "base_dir", tmp_path):
|
|
|
|
|
+ return layer_timelapse.TimelapseSession(
|
|
|
|
|
+ printer_id=PRINTER_ID,
|
|
|
|
|
+ archive_id=None,
|
|
|
|
|
+ camera_url="/dev/video0",
|
|
|
|
|
+ camera_type="usb",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_timelapse_uses_the_live_frame_instead_of_competing(tmp_path):
|
|
|
|
|
+ session = _session(tmp_path)
|
|
|
|
|
+ _attach_viewer()
|
|
|
|
|
+
|
|
|
|
|
+ with patch.object(layer_timelapse, "capture_frame", new=AsyncMock(return_value=FRESH_FRAME)) as mock_capture:
|
|
|
|
|
+ captured = await session.capture_layer(1)
|
|
|
|
|
+
|
|
|
|
|
+ assert captured is True, "layer capture failed with a viewer attached"
|
|
|
|
|
+ # Would have opened a competing handle on a single-reader device.
|
|
|
|
|
+ mock_capture.assert_not_called()
|
|
|
|
|
+ written = sorted(session.frames_dir.glob("layer_*.jpg"))
|
|
|
|
|
+ assert len(written) == 1
|
|
|
|
|
+ assert written[0].read_bytes() == LIVE_FRAME
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_timelapse_skips_a_layer_rather_than_competing_on_an_empty_buffer(tmp_path):
|
|
|
|
|
+ session = _session(tmp_path)
|
|
|
|
|
+ _attach_viewer(frame=None)
|
|
|
|
|
+
|
|
|
|
|
+ with patch.object(layer_timelapse, "capture_frame", new=AsyncMock(return_value=FRESH_FRAME)) as mock_capture:
|
|
|
|
|
+ captured = await session.capture_layer(1)
|
|
|
|
|
+
|
|
|
|
|
+ assert captured is False
|
|
|
|
|
+ mock_capture.assert_not_called()
|
|
|
|
|
+ assert sorted(session.frames_dir.glob("layer_*.jpg")) == []
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_timelapse_captures_normally_with_no_viewer(tmp_path):
|
|
|
|
|
+ """The unwatched path must be untouched — this is the common case."""
|
|
|
|
|
+ session = _session(tmp_path)
|
|
|
|
|
+
|
|
|
|
|
+ with patch.object(layer_timelapse, "capture_frame", new=AsyncMock(return_value=FRESH_FRAME)) as mock_capture:
|
|
|
|
|
+ captured = await session.capture_layer(1)
|
|
|
|
|
+
|
|
|
|
|
+ assert captured is True
|
|
|
|
|
+ mock_capture.assert_awaited_once()
|
|
|
|
|
+ written = sorted(session.frames_dir.glob("layer_*.jpg"))
|
|
|
|
|
+ assert written[0].read_bytes() == FRESH_FRAME
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# Obico polling — external branch, mirroring the built-in one
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _external_printer() -> MagicMock:
|
|
|
|
|
+ return MagicMock(
|
|
|
|
|
+ external_camera_enabled=True,
|
|
|
|
|
+ external_camera_url="/dev/video0",
|
|
|
|
|
+ external_camera_type="usb",
|
|
|
|
|
+ external_camera_snapshot_url=None,
|
|
|
|
|
+ ip_address="192.168.1.10",
|
|
|
|
|
+ access_code="12345678",
|
|
|
|
|
+ model="A1",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _db_returning(printer) -> MagicMock:
|
|
|
|
|
+ session = MagicMock()
|
|
|
|
|
+ session.get = AsyncMock(return_value=printer)
|
|
|
|
|
+ ctx = MagicMock()
|
|
|
|
|
+ ctx.__aenter__ = AsyncMock(return_value=session)
|
|
|
|
|
+ ctx.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
+ return ctx
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_obico_reuses_the_live_external_frame():
|
|
|
|
|
+ _attach_viewer()
|
|
|
|
|
+ svc = ObicoDetectionService()
|
|
|
|
|
+
|
|
|
|
|
+ with (
|
|
|
|
|
+ patch(
|
|
|
|
|
+ "backend.app.services.obico_detection.async_session",
|
|
|
|
|
+ return_value=_db_returning(_external_printer()),
|
|
|
|
|
+ ),
|
|
|
|
|
+ patch(
|
|
|
|
|
+ "backend.app.services.external_camera.capture_frame",
|
|
|
|
|
+ new=AsyncMock(return_value=FRESH_FRAME),
|
|
|
|
|
+ ) as mock_capture,
|
|
|
|
|
+ ):
|
|
|
|
|
+ result = await svc._capture_frame(printer_id=PRINTER_ID)
|
|
|
|
|
+
|
|
|
|
|
+ assert result == LIVE_FRAME
|
|
|
|
|
+ mock_capture.assert_not_called()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_obico_skips_the_poll_when_the_external_buffer_is_empty():
|
|
|
|
|
+ _attach_viewer(frame=None)
|
|
|
|
|
+ svc = ObicoDetectionService()
|
|
|
|
|
+
|
|
|
|
|
+ with (
|
|
|
|
|
+ patch(
|
|
|
|
|
+ "backend.app.services.obico_detection.async_session",
|
|
|
|
|
+ return_value=_db_returning(_external_printer()),
|
|
|
|
|
+ ),
|
|
|
|
|
+ patch(
|
|
|
|
|
+ "backend.app.services.external_camera.capture_frame",
|
|
|
|
|
+ new=AsyncMock(return_value=FRESH_FRAME),
|
|
|
|
|
+ ) as mock_capture,
|
|
|
|
|
+ ):
|
|
|
|
|
+ result = await svc._capture_frame(printer_id=PRINTER_ID)
|
|
|
|
|
+
|
|
|
|
|
+ assert result is None
|
|
|
|
|
+ mock_capture.assert_not_called()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_obico_still_captures_when_nobody_is_watching():
|
|
|
|
|
+ svc = ObicoDetectionService()
|
|
|
|
|
+
|
|
|
|
|
+ with (
|
|
|
|
|
+ patch(
|
|
|
|
|
+ "backend.app.services.obico_detection.async_session",
|
|
|
|
|
+ return_value=_db_returning(_external_printer()),
|
|
|
|
|
+ ),
|
|
|
|
|
+ patch(
|
|
|
|
|
+ "backend.app.services.external_camera.capture_frame",
|
|
|
|
|
+ new=AsyncMock(return_value=FRESH_FRAME),
|
|
|
|
|
+ ) as mock_capture,
|
|
|
|
|
+ ):
|
|
|
|
|
+ result = await svc._capture_frame(printer_id=PRINTER_ID)
|
|
|
|
|
+
|
|
|
|
|
+ assert result == FRESH_FRAME
|
|
|
|
|
+ mock_capture.assert_awaited_once()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# Plate detection — its docstring already promised this
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_plate_detection_reuses_the_live_external_frame():
|
|
|
|
|
+ from backend.app.services import plate_detection
|
|
|
|
|
+
|
|
|
|
|
+ _attach_viewer()
|
|
|
|
|
+
|
|
|
|
|
+ with patch(
|
|
|
|
|
+ "backend.app.services.external_camera.capture_frame",
|
|
|
|
|
+ new=AsyncMock(return_value=FRESH_FRAME),
|
|
|
|
|
+ ) as mock_capture:
|
|
|
|
|
+ image, source = await plate_detection.capture_camera_image(
|
|
|
|
|
+ printer_id=PRINTER_ID,
|
|
|
|
|
+ ip_address="192.168.1.10",
|
|
|
|
|
+ access_code="12345678",
|
|
|
|
|
+ model="A1",
|
|
|
|
|
+ external_camera_url="/dev/video0",
|
|
|
|
|
+ external_camera_type="usb",
|
|
|
|
|
+ use_external=True,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert image == LIVE_FRAME
|
|
|
|
|
+ assert source == "external (buffered)"
|
|
|
|
|
+ mock_capture.assert_not_called()
|