test_camera_external_fanout.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. """External camera streams share one upstream per printer (RC-C6).
  2. Every viewer of an external camera started its own upstream: its own ffmpeg,
  3. MJPEG connection or snapshot poll. A USB camera allows one V4L2 handle, so a
  4. second viewer (a Cam Wall tile and the camera page, two browsers) failed with
  5. "device busy". External cameras now use the same fan-out broadcaster as the
  6. printer's own camera (#1089).
  7. """
  8. import asyncio
  9. from unittest.mock import AsyncMock, MagicMock, patch
  10. import pytest
  11. from backend.app.api.routes import camera
  12. from backend.app.models.printer import Printer
  13. from backend.app.services import camera_fanout
  14. pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
  15. class _Proc:
  16. def __init__(self, pid: int) -> None:
  17. self.pid = pid
  18. self.returncode = None
  19. self.stdout = None
  20. self.stderr = None
  21. def terminate(self) -> None:
  22. self.returncode = 0
  23. def kill(self) -> None:
  24. self.returncode = -9
  25. async def wait(self) -> int:
  26. return self.returncode if self.returncode is not None else 0
  27. class _Upstream:
  28. """A fake generate_mjpeg_stream that counts how often a camera is opened."""
  29. def __init__(self) -> None:
  30. self.opened: list[tuple[str, str]] = []
  31. self.closed = 0
  32. async def __call__(self, url, camera_type, fps, *, on_process=None, on_frame=None, stop_event=None):
  33. self.opened.append((url, camera_type))
  34. if on_process is not None:
  35. on_process(_Proc(60000 + len(self.opened)))
  36. try:
  37. while stop_event is None or not stop_event.is_set():
  38. if on_frame is not None:
  39. on_frame(b"\xff\xd8frame\xff\xd9")
  40. yield b"--frame\r\nContent-Type: image/jpeg\r\n\r\n\xff\xd8frame\xff\xd9\r\n"
  41. await asyncio.sleep(0.01)
  42. finally:
  43. self.closed += 1
  44. @pytest.fixture
  45. def upstream(monkeypatch):
  46. fake = _Upstream()
  47. monkeypatch.setattr(camera_fanout, "_GRACE_SECONDS", 0.05)
  48. with patch("backend.app.services.external_camera.generate_mjpeg_stream", fake):
  49. yield fake
  50. @pytest.fixture
  51. async def usb_printer(async_client, db_session) -> int:
  52. # async_client points database.async_session, which camera_stream opens, at the test DB.
  53. row = Printer(
  54. name="USB cam",
  55. serial_number="CAM-FANOUT",
  56. ip_address="192.0.2.71",
  57. access_code="x",
  58. external_camera_enabled=True,
  59. external_camera_url="/dev/video0",
  60. external_camera_type="usb",
  61. )
  62. db_session.add(row)
  63. await db_session.commit()
  64. yield row.id
  65. await camera.forget_printer(row.id)
  66. def _request() -> MagicMock:
  67. request = MagicMock()
  68. request.is_disconnected = AsyncMock(return_value=False)
  69. return request
  70. async def _open(printer_id: int, viewer: str | None = None):
  71. response = await camera.camera_stream(printer_id, _request(), fps=5, opener=None, viewer=viewer)
  72. stream = response.body_iterator
  73. await asyncio.wait_for(anext(stream), 2)
  74. return stream
  75. async def _settle() -> None:
  76. await asyncio.sleep(0.15)
  77. async def test_two_viewers_share_one_upstream(upstream, usb_printer):
  78. tile = await _open(usb_printer, "tile-a")
  79. page = await _open(usb_printer, "page-b")
  80. await asyncio.wait_for(anext(tile), 2)
  81. await asyncio.wait_for(anext(page), 2)
  82. assert upstream.opened == [("/dev/video0", "usb")]
  83. usb_streams = [sid for sid in camera._active_streams if sid.startswith(f"{usb_printer}-ext-")]
  84. assert len(usb_streams) == 1
  85. await tile.aclose()
  86. await page.aclose()
  87. async def test_the_other_viewer_keeps_watching_when_one_leaves(upstream, usb_printer):
  88. tile = await _open(usb_printer)
  89. page = await _open(usb_printer)
  90. await tile.aclose()
  91. assert await asyncio.wait_for(anext(page), 2)
  92. assert (await camera.camera_status(usb_printer, None))["active"]
  93. assert upstream.closed == 0
  94. await page.aclose()
  95. async def test_the_upstream_closes_after_the_last_viewer(upstream, usb_printer):
  96. page = await _open(usb_printer)
  97. await page.aclose()
  98. await _settle()
  99. assert upstream.closed == 1
  100. assert not any(sid.startswith(f"{usb_printer}-ext-") for sid in camera._active_streams)
  101. assert not (await camera.camera_status(usb_printer, None))["active"]
  102. async def test_a_viewer_back_within_the_grace_window_reuses_the_upstream(upstream, usb_printer, monkeypatch):
  103. """A page reload: closing and reopening a USB camera costs seconds."""
  104. monkeypatch.setattr(camera_fanout, "_GRACE_SECONDS", 5.0)
  105. first = await _open(usb_printer)
  106. await first.aclose()
  107. again = await _open(usb_printer)
  108. assert len(upstream.opened) == 1
  109. status = await camera.camera_status(usb_printer, None)
  110. assert status["active"] and status["stream_uptime"] is not None
  111. await again.aclose()
  112. async def test_a_stop_while_another_viewer_watches_leaves_the_stream(upstream, usb_printer):
  113. page = await _open(usb_printer, "page-b")
  114. result = await camera.stop_camera_stream(usb_printer, _=None, viewer="tile-a")
  115. assert result.get("skipped") is True
  116. assert await asyncio.wait_for(anext(page), 2)
  117. await page.aclose()
  118. async def test_a_stop_with_nobody_watching_ends_the_upstream(upstream, usb_printer, monkeypatch):
  119. monkeypatch.setattr(camera_fanout, "_GRACE_SECONDS", 5.0)
  120. page = await _open(usb_printer)
  121. await page.aclose()
  122. await camera.stop_camera_stream(usb_printer, _=None)
  123. assert upstream.closed == 1
  124. assert not any(sid.startswith(f"{usb_printer}-ext-") for sid in camera._active_streams)
  125. async def test_a_changed_camera_gets_its_own_upstream(upstream, usb_printer, db_session):
  126. old = await _open(usb_printer)
  127. row = await db_session.get(Printer, usb_printer)
  128. row.external_camera_url = "/dev/video2"
  129. await db_session.commit()
  130. new = await _open(usb_printer)
  131. assert upstream.opened == [("/dev/video0", "usb"), ("/dev/video2", "usb")]
  132. # The old camera's viewer is ended rather than left on the old device.
  133. with pytest.raises(StopAsyncIteration):
  134. for _ in range(20):
  135. await asyncio.wait_for(anext(old), 2)
  136. assert await asyncio.wait_for(anext(new), 2)
  137. await new.aclose()
  138. async def test_a_deleted_printer_ends_the_shared_stream(upstream, usb_printer):
  139. page = await _open(usb_printer)
  140. await camera.forget_printer(usb_printer)
  141. with pytest.raises(StopAsyncIteration):
  142. for _ in range(20):
  143. await asyncio.wait_for(anext(page), 2)
  144. await _settle()
  145. assert upstream.closed == 1
  146. async def test_the_live_frame_is_published_for_one_shot_captures(upstream, usb_printer):
  147. page = await _open(usb_printer)
  148. defer, frame = camera.live_frame_for_capture(usb_printer)
  149. assert defer and frame == b"\xff\xd8frame\xff\xd9"
  150. await page.aclose()
  151. async def test_a_snapshot_in_the_grace_window_gets_the_live_frame(upstream, usb_printer, monkeypatch):
  152. """The upstream still holds the camera for a few seconds after the last viewer."""
  153. monkeypatch.setattr(camera_fanout, "_GRACE_SECONDS", 5.0)
  154. page = await _open(usb_printer)
  155. await page.aclose()
  156. # Only frames taken after the viewer left count.
  157. camera._last_frames.pop(usb_printer, None)
  158. await asyncio.sleep(0.05)
  159. defer, frame = camera.live_frame_for_capture(usb_printer)
  160. assert defer and frame == b"\xff\xd8frame\xff\xd9"
  161. assert not (await camera.camera_status(usb_printer, None))["stalled"]