test_camera_live_view_snapshots.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. """Snapshots and the external camera test stand down for a live view (SP-C2).
  2. A USB camera allows one V4L2 handle, so an external snapshot taken while a
  3. viewer was watching failed with 503 on every Home Assistant or camera-wall
  4. poll, and the Test button failed the same way. Finish photos and timelapse
  5. frames already reuse the live frame (``live_frame_for_capture``); the
  6. snapshot route and the Test button now do too.
  7. """
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import pytest
  10. from httpx import AsyncClient
  11. # 640x480 baseline JPEG header: SOI, then SOF0 with height 480 and width 640.
  12. LIVE_JPEG = b"\xff\xd8\xff\xc0\x00\x11\x08\x01\xe0\x02\x80\x03" + b"\x00" * 120 + b"\xff\xd9"
  13. USB = {"external_camera_enabled": True, "external_camera_url": "/dev/video0", "external_camera_type": "usb"}
  14. def _live_view(printer_id: int, frame: bytes | None):
  15. streams = {f"{printer_id}-fanout": MagicMock()}
  16. frames = {printer_id: frame} if frame is not None else {}
  17. return (
  18. patch("backend.app.api.routes.camera._active_streams", streams),
  19. patch("backend.app.api.routes.camera._last_frames", frames),
  20. )
  21. @pytest.mark.asyncio
  22. @pytest.mark.integration
  23. async def test_external_snapshot_uses_the_live_frame(async_client: AsyncClient, printer_factory):
  24. printer = await printer_factory(**USB)
  25. streams, frames = _live_view(printer.id, LIVE_JPEG)
  26. with (
  27. streams,
  28. frames,
  29. patch("backend.app.services.external_camera.capture_frame", new_callable=AsyncMock) as capture,
  30. ):
  31. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
  32. assert response.status_code == 200
  33. assert response.content == LIVE_JPEG
  34. capture.assert_not_called()
  35. @pytest.mark.asyncio
  36. @pytest.mark.integration
  37. async def test_external_snapshot_waits_for_a_starting_live_view(async_client: AsyncClient, printer_factory):
  38. """No frame yet: opening the camera now would knock the viewer off (#1348)."""
  39. printer = await printer_factory(**USB)
  40. streams, frames = _live_view(printer.id, None)
  41. with (
  42. streams,
  43. frames,
  44. patch("backend.app.services.external_camera.capture_frame", new_callable=AsyncMock) as capture,
  45. ):
  46. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
  47. assert response.status_code == 503
  48. assert response.headers.get("retry-after") == "2"
  49. capture.assert_not_called()
  50. @pytest.mark.asyncio
  51. @pytest.mark.integration
  52. async def test_bambu_snapshot_waits_for_a_starting_live_view(async_client: AsyncClient, printer_factory):
  53. printer = await printer_factory()
  54. streams, frames = _live_view(printer.id, None)
  55. with (
  56. streams,
  57. frames,
  58. patch("backend.app.api.routes.camera.capture_camera_frame", new_callable=AsyncMock) as capture,
  59. ):
  60. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
  61. assert response.status_code == 503
  62. capture.assert_not_called()
  63. @pytest.mark.asyncio
  64. @pytest.mark.integration
  65. async def test_testing_the_watched_camera_reports_it_working(async_client: AsyncClient, printer_factory):
  66. printer = await printer_factory(**USB)
  67. streams, frames = _live_view(printer.id, LIVE_JPEG)
  68. with (
  69. streams,
  70. frames,
  71. patch("backend.app.services.external_camera.test_connection", new_callable=AsyncMock) as probe,
  72. ):
  73. response = await async_client.post(
  74. f"/api/v1/printers/{printer.id}/camera/external/test",
  75. json={"url": "/dev/video0", "camera_type": "usb"},
  76. )
  77. assert response.status_code == 200
  78. assert response.json() == {"success": True, "resolution": "640x480", "coalesced": True, "live_view": True}
  79. probe.assert_not_called()
  80. @pytest.mark.asyncio
  81. @pytest.mark.integration
  82. async def test_testing_the_watched_camera_while_the_view_starts(async_client: AsyncClient, printer_factory):
  83. printer = await printer_factory(**USB)
  84. streams, frames = _live_view(printer.id, None)
  85. with (
  86. streams,
  87. frames,
  88. patch("backend.app.services.external_camera.test_connection", new_callable=AsyncMock) as probe,
  89. ):
  90. response = await async_client.post(
  91. f"/api/v1/printers/{printer.id}/camera/external/test",
  92. json={"url": "/dev/video0", "camera_type": "usb"},
  93. )
  94. body = response.json()
  95. assert body["success"] is False
  96. assert body["live_view"] is True
  97. probe.assert_not_called()
  98. @pytest.mark.asyncio
  99. @pytest.mark.integration
  100. async def test_testing_another_camera_still_opens_it(async_client: AsyncClient, printer_factory):
  101. """The form may hold a device that is not the one being watched."""
  102. printer = await printer_factory(**USB)
  103. streams, frames = _live_view(printer.id, LIVE_JPEG)
  104. with (
  105. streams,
  106. frames,
  107. patch(
  108. "backend.app.services.external_camera.test_connection",
  109. new_callable=AsyncMock,
  110. return_value={"success": True, "resolution": "1280x720", "coalesced": False},
  111. ) as probe,
  112. ):
  113. response = await async_client.post(
  114. f"/api/v1/printers/{printer.id}/camera/external/test",
  115. json={"url": "/dev/video2", "camera_type": "usb"},
  116. )
  117. assert response.json()["resolution"] == "1280x720"
  118. probe.assert_awaited_once_with("/dev/video2", "usb")
  119. @pytest.mark.asyncio
  120. @pytest.mark.integration
  121. async def test_a_dedicated_snapshot_url_is_still_used_during_a_live_view(async_client: AsyncClient, printer_factory):
  122. """#1177: an HTTP snapshot endpoint has no reader to compete with, and gives
  123. the full-quality frame the live view does not."""
  124. printer = await printer_factory(
  125. external_camera_enabled=True,
  126. external_camera_url="rtsp://10.0.0.9/live",
  127. external_camera_type="rtsp",
  128. external_camera_snapshot_url="http://10.0.0.9/snapshot.jpg",
  129. )
  130. streams, frames = _live_view(printer.id, None)
  131. with (
  132. streams,
  133. frames,
  134. patch(
  135. "backend.app.services.external_camera.capture_frame", new_callable=AsyncMock, return_value=LIVE_JPEG
  136. ) as capture,
  137. ):
  138. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
  139. assert response.status_code == 200
  140. capture.assert_awaited_once()
  141. assert capture.await_args.kwargs["snapshot_url"] == "http://10.0.0.9/snapshot.jpg"