test_external_camera_live_frame_reuse.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. """External-camera captures must reuse the live view's frame (#2707).
  2. A USB camera allows exactly one V4L2 handle, so a one-shot capture taken while
  3. somebody is watching the live view doesn't degrade — it fails. The reporter
  4. measured 0 of 87 and 0 of 105 layer-timelapse captures on prints watched from
  5. start to finish, and finish-photo notifications going out with no image.
  6. The guards for the built-in camera (#1348, #1271) were never extended to the
  7. external paths, and the deeper reason they couldn't be: ``_last_frames`` was
  8. only ever populated by the built-in paths. ``generate_mjpeg_stream`` yields
  9. multipart-wrapped chunks, so the route layer had no way to recover the JPEG —
  10. hence the ``on_frame`` callback, and hence a guard alone would have found an
  11. empty buffer and skipped every time.
  12. These tests cover the plumbing (raw frames reach the callback) and each consumer
  13. that used to compete: layer timelapse, Obico polling, and plate detection.
  14. """
  15. from __future__ import annotations
  16. from unittest.mock import AsyncMock, MagicMock, patch
  17. import pytest
  18. from backend.app.api.routes import camera
  19. from backend.app.services import external_camera, layer_timelapse
  20. from backend.app.services.obico_detection import ObicoDetectionService
  21. pytestmark = pytest.mark.asyncio
  22. LIVE_FRAME = b"\xff\xd8live-viewer-frame\xff\xd9"
  23. FRESH_FRAME = b"\xff\xd8fresh-capture\xff\xd9"
  24. PRINTER_ID = 9310
  25. @pytest.fixture(autouse=True)
  26. def _clean_registries():
  27. def _purge():
  28. for sid in [k for k in camera._active_streams if k.startswith(f"{PRINTER_ID}-")]:
  29. camera._active_streams.pop(sid, None)
  30. camera._last_frames.pop(PRINTER_ID, None)
  31. camera._last_frame_times.pop(PRINTER_ID, None)
  32. camera._stream_start_times.pop(PRINTER_ID, None)
  33. _purge()
  34. yield
  35. _purge()
  36. def _attach_viewer(frame: bytes | None = LIVE_FRAME) -> None:
  37. """Register a live external stream, as the stream route does."""
  38. camera._active_streams[f"{PRINTER_ID}-ext-deadbeef"] = object()
  39. if frame is not None:
  40. camera._last_frames[PRINTER_ID] = frame
  41. # ---------------------------------------------------------------------------
  42. # live_frame_for_capture — the shared decision
  43. # ---------------------------------------------------------------------------
  44. async def test_no_viewer_means_capture_normally():
  45. defer, frame = camera.live_frame_for_capture(PRINTER_ID)
  46. assert defer is False
  47. assert frame is None
  48. async def test_viewer_with_a_buffered_frame_is_reused():
  49. _attach_viewer()
  50. defer, frame = camera.live_frame_for_capture(PRINTER_ID)
  51. assert defer is True
  52. assert frame == LIVE_FRAME
  53. async def test_viewer_with_an_empty_buffer_means_skip_not_capture():
  54. """#1348: competing for the device is worse than missing one frame."""
  55. _attach_viewer(frame=None)
  56. defer, frame = camera.live_frame_for_capture(PRINTER_ID)
  57. assert defer is True
  58. assert frame is None
  59. # ---------------------------------------------------------------------------
  60. # on_frame plumbing — without this the buffer is always empty
  61. # ---------------------------------------------------------------------------
  62. async def test_on_frame_receives_the_raw_jpeg_not_the_multipart_chunk():
  63. """The consumers want a JPEG; the stream yields multipart. Hence a callback."""
  64. captured: list[bytes] = []
  65. async def _fake_usb(_url, _fps, on_process=None):
  66. yield FRESH_FRAME
  67. with patch.object(external_camera, "_stream_usb", _fake_usb):
  68. chunks = [
  69. chunk
  70. async for chunk in external_camera.generate_mjpeg_stream(
  71. "/dev/video0", "usb", fps=15, on_frame=captured.append
  72. )
  73. ]
  74. assert captured == [FRESH_FRAME], "callback did not get the raw frame"
  75. assert b"--frame" in chunks[0], "wire format should still be multipart"
  76. assert b"--frame" not in captured[0]
  77. async def test_a_raising_on_frame_callback_cannot_break_the_stream():
  78. """Buffering is a side effect; it must never take the live view down."""
  79. async def _fake_usb(_url, _fps, on_process=None):
  80. yield FRESH_FRAME
  81. yield FRESH_FRAME
  82. def _boom(_frame: bytes) -> None:
  83. raise RuntimeError("buffering blew up")
  84. with patch.object(external_camera, "_stream_usb", _fake_usb):
  85. chunks = [
  86. chunk async for chunk in external_camera.generate_mjpeg_stream("/dev/video0", "usb", fps=15, on_frame=_boom)
  87. ]
  88. assert len(chunks) == 2, "stream stopped because the callback raised"
  89. # ---------------------------------------------------------------------------
  90. # Layer timelapse — the 0-of-87 case
  91. # ---------------------------------------------------------------------------
  92. def _session(tmp_path) -> layer_timelapse.TimelapseSession:
  93. with patch.object(layer_timelapse.settings, "base_dir", tmp_path):
  94. return layer_timelapse.TimelapseSession(
  95. printer_id=PRINTER_ID,
  96. archive_id=None,
  97. camera_url="/dev/video0",
  98. camera_type="usb",
  99. )
  100. async def test_timelapse_uses_the_live_frame_instead_of_competing(tmp_path):
  101. session = _session(tmp_path)
  102. _attach_viewer()
  103. with patch.object(layer_timelapse, "capture_frame", new=AsyncMock(return_value=FRESH_FRAME)) as mock_capture:
  104. captured = await session.capture_layer(1)
  105. assert captured is True, "layer capture failed with a viewer attached"
  106. # Would have opened a competing handle on a single-reader device.
  107. mock_capture.assert_not_called()
  108. written = sorted(session.frames_dir.glob("layer_*.jpg"))
  109. assert len(written) == 1
  110. assert written[0].read_bytes() == LIVE_FRAME
  111. async def test_timelapse_skips_a_layer_rather_than_competing_on_an_empty_buffer(tmp_path):
  112. session = _session(tmp_path)
  113. _attach_viewer(frame=None)
  114. with patch.object(layer_timelapse, "capture_frame", new=AsyncMock(return_value=FRESH_FRAME)) as mock_capture:
  115. captured = await session.capture_layer(1)
  116. assert captured is False
  117. mock_capture.assert_not_called()
  118. assert sorted(session.frames_dir.glob("layer_*.jpg")) == []
  119. async def test_timelapse_captures_normally_with_no_viewer(tmp_path):
  120. """The unwatched path must be untouched — this is the common case."""
  121. session = _session(tmp_path)
  122. with patch.object(layer_timelapse, "capture_frame", new=AsyncMock(return_value=FRESH_FRAME)) as mock_capture:
  123. captured = await session.capture_layer(1)
  124. assert captured is True
  125. mock_capture.assert_awaited_once()
  126. written = sorted(session.frames_dir.glob("layer_*.jpg"))
  127. assert written[0].read_bytes() == FRESH_FRAME
  128. # ---------------------------------------------------------------------------
  129. # Obico polling — external branch, mirroring the built-in one
  130. # ---------------------------------------------------------------------------
  131. def _external_printer() -> MagicMock:
  132. return MagicMock(
  133. external_camera_enabled=True,
  134. external_camera_url="/dev/video0",
  135. external_camera_type="usb",
  136. external_camera_snapshot_url=None,
  137. ip_address="192.168.1.10",
  138. access_code="12345678",
  139. model="A1",
  140. )
  141. def _db_returning(printer) -> MagicMock:
  142. session = MagicMock()
  143. session.get = AsyncMock(return_value=printer)
  144. ctx = MagicMock()
  145. ctx.__aenter__ = AsyncMock(return_value=session)
  146. ctx.__aexit__ = AsyncMock(return_value=None)
  147. return ctx
  148. async def test_obico_reuses_the_live_external_frame():
  149. _attach_viewer()
  150. svc = ObicoDetectionService()
  151. with (
  152. patch(
  153. "backend.app.services.obico_detection.async_session",
  154. return_value=_db_returning(_external_printer()),
  155. ),
  156. patch(
  157. "backend.app.services.external_camera.capture_frame",
  158. new=AsyncMock(return_value=FRESH_FRAME),
  159. ) as mock_capture,
  160. ):
  161. result = await svc._capture_frame(printer_id=PRINTER_ID)
  162. assert result == LIVE_FRAME
  163. mock_capture.assert_not_called()
  164. async def test_obico_skips_the_poll_when_the_external_buffer_is_empty():
  165. _attach_viewer(frame=None)
  166. svc = ObicoDetectionService()
  167. with (
  168. patch(
  169. "backend.app.services.obico_detection.async_session",
  170. return_value=_db_returning(_external_printer()),
  171. ),
  172. patch(
  173. "backend.app.services.external_camera.capture_frame",
  174. new=AsyncMock(return_value=FRESH_FRAME),
  175. ) as mock_capture,
  176. ):
  177. result = await svc._capture_frame(printer_id=PRINTER_ID)
  178. assert result is None
  179. mock_capture.assert_not_called()
  180. async def test_obico_still_captures_when_nobody_is_watching():
  181. svc = ObicoDetectionService()
  182. with (
  183. patch(
  184. "backend.app.services.obico_detection.async_session",
  185. return_value=_db_returning(_external_printer()),
  186. ),
  187. patch(
  188. "backend.app.services.external_camera.capture_frame",
  189. new=AsyncMock(return_value=FRESH_FRAME),
  190. ) as mock_capture,
  191. ):
  192. result = await svc._capture_frame(printer_id=PRINTER_ID)
  193. assert result == FRESH_FRAME
  194. mock_capture.assert_awaited_once()
  195. # ---------------------------------------------------------------------------
  196. # Plate detection — its docstring already promised this
  197. # ---------------------------------------------------------------------------
  198. async def test_plate_detection_reuses_the_live_external_frame():
  199. from backend.app.services import plate_detection
  200. _attach_viewer()
  201. with patch(
  202. "backend.app.services.external_camera.capture_frame",
  203. new=AsyncMock(return_value=FRESH_FRAME),
  204. ) as mock_capture:
  205. image, source = await plate_detection.capture_camera_image(
  206. printer_id=PRINTER_ID,
  207. ip_address="192.168.1.10",
  208. access_code="12345678",
  209. model="A1",
  210. external_camera_url="/dev/video0",
  211. external_camera_type="usb",
  212. use_external=True,
  213. )
  214. assert image == LIVE_FRAME
  215. assert source == "external (buffered)"
  216. mock_capture.assert_not_called()