test_external_rtsp_frozen_frames.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. """A frozen external RTSP camera is reconnected (RC-C1).
  2. ffmpeg can go on repeating its last frame after the camera has stopped sending
  3. (`-r fps` duplicates it), so frame counting sees a healthy stream: status says
  4. live, and timelapse, finish photos and Obico reuse a picture that stopped
  5. hours ago. The printer's own RTSP stream restarts ffmpeg after 20 s of
  6. byte-identical frames (#3218); the external one now does the same, with the
  7. same still-scene rule for a camera that really shows an unchanging picture.
  8. """
  9. import asyncio
  10. from unittest.mock import AsyncMock, MagicMock
  11. import pytest
  12. from backend.app.services import external_camera
  13. from backend.app.services.external_camera import _FreezeWatch
  14. A = b"\xff\xd8" + b"a" * 200 + b"\xff\xd9"
  15. B = b"\xff\xd8" + b"b" * 200 + b"\xff\xd9"
  16. class TestTheFreezeWatch:
  17. def test_a_moving_picture_never_freezes(self):
  18. watch = _FreezeWatch()
  19. watch.start_session()
  20. assert not any(watch.frozen(A if i % 2 else B, float(i)) for i in range(100))
  21. def test_the_same_frame_for_twenty_seconds_is_frozen(self):
  22. watch = _FreezeWatch()
  23. watch.start_session()
  24. assert not watch.frozen(A, 0.0)
  25. assert not watch.frozen(A, 19.0)
  26. assert watch.frozen(A, 21.0)
  27. def test_the_same_picture_after_a_restart_is_a_still_scene(self):
  28. """A dark, idle chamber can encode to the same frame every time: then
  29. the stream only re-checks every few minutes."""
  30. watch = _FreezeWatch()
  31. watch.start_session()
  32. watch.frozen(A, 0.0)
  33. assert watch.frozen(A, 21.0)
  34. watch.start_session()
  35. assert not watch.frozen(A, 22.0)
  36. assert not watch.frozen(A, 22.0 + 299.0)
  37. assert watch.frozen(A, 22.0 + 301.0)
  38. def test_a_changed_picture_ends_the_still_scene(self):
  39. watch = _FreezeWatch()
  40. watch.start_session()
  41. watch.frozen(A, 0.0)
  42. watch.frozen(A, 21.0)
  43. watch.start_session()
  44. watch.frozen(A, 22.0)
  45. watch.frozen(B, 23.0)
  46. assert not watch.frozen(B, 40.0)
  47. assert watch.frozen(B, 44.0)
  48. class TestASlowOrQuietCamera:
  49. """Unlike a Bambu camera, an external one can legitimately repeat frames for
  50. longer than 20 s: a source slower than one picture per 20 s (ffmpeg's -r
  51. repeats each until the next), or a surveillance camera's smart codec on a
  52. static night scene. Restarting those every 20 s would break a camera that
  53. worked; the limit grows to three times the longest gap seen between real
  54. changes, up to the still-scene interval."""
  55. def test_a_slow_source_is_restarted_once_then_left_alone(self):
  56. watch = _FreezeWatch()
  57. watch.start_session()
  58. watch.frozen(A, 0.0)
  59. assert watch.frozen(A, 21.0) # looked frozen: no way to know yet
  60. # After the restart the picture is a new one: it had not frozen, it is slow.
  61. watch.start_session()
  62. assert not watch.frozen(B, 23.0)
  63. assert not watch.frozen(B, 23.0 + 60.0) # 3 x the 21 s gap it showed
  64. assert watch.frozen(B, 23.0 + 64.0)
  65. def test_a_gap_seen_within_a_session_raises_the_limit(self):
  66. watch = _FreezeWatch()
  67. watch.start_session()
  68. watch.frozen(A, 0.0)
  69. watch.frozen(B, 15.0) # a 15 s gap between real pictures
  70. assert not watch.frozen(B, 15.0 + 44.0)
  71. assert watch.frozen(B, 15.0 + 46.0)
  72. def test_the_limit_never_exceeds_the_still_scene_interval(self):
  73. watch = _FreezeWatch()
  74. watch.start_session()
  75. watch.frozen(A, 0.0)
  76. watch.frozen(B, 200.0) # 3 x 200 s would be 10 minutes
  77. assert watch.frozen(B, 200.0 + 301.0)
  78. class _Ffmpeg:
  79. """An ffmpeg that repeats one frame forever, as after a dead upstream."""
  80. def __init__(self, frame: bytes) -> None:
  81. self.pid = 53001
  82. self.returncode = None
  83. self.stdout = MagicMock()
  84. self.stderr = asyncio.StreamReader()
  85. self.stderr.feed_eof()
  86. self._frame = frame
  87. async def read(_n):
  88. await asyncio.sleep(0.005)
  89. return self._frame
  90. self.stdout.read = read
  91. def terminate(self) -> None:
  92. self.returncode = 0
  93. def kill(self) -> None:
  94. self.returncode = -9
  95. async def wait(self) -> int:
  96. return 0
  97. @pytest.fixture
  98. def ffmpeg(monkeypatch):
  99. spawned: list[_Ffmpeg] = []
  100. async def spawn(*_args, **_kwargs):
  101. proc = _Ffmpeg(A)
  102. spawned.append(proc)
  103. return proc
  104. monkeypatch.setattr(external_camera, "get_ffmpeg_path", lambda: "/usr/bin/ffmpeg")
  105. monkeypatch.setattr(external_camera, "read_ffmpeg_log_until_open", AsyncMock(return_value=("open", b"")))
  106. monkeypatch.setattr(external_camera, "watch_ffmpeg_log", AsyncMock(return_value=None))
  107. monkeypatch.setattr("asyncio.create_subprocess_exec", spawn)
  108. monkeypatch.setattr(external_camera, "_EXTERNAL_RTSP_FROZEN_SECONDS", 0.1)
  109. monkeypatch.setattr(external_camera.asyncio, "sleep", _fast_sleep(asyncio.sleep))
  110. return spawned
  111. def _fast_sleep(real_sleep):
  112. async def sleep(delay, *args, **kwargs):
  113. # The reconnect pause; the frame pacing stays as it is.
  114. return await real_sleep(min(delay, 0.01), *args, **kwargs)
  115. return sleep
  116. @pytest.mark.asyncio
  117. async def test_a_frozen_session_ends(ffmpeg):
  118. session = external_camera._stream_rtsp("rtsp://192.0.2.80/live", 5, freeze=_FreezeWatch())
  119. frames = 0
  120. async with asyncio.timeout(3):
  121. async for _ in session:
  122. frames += 1
  123. assert frames > 1
  124. # It ended on the freeze, not on its way out: ffmpeg was still running.
  125. assert ffmpeg[0].returncode is not None
  126. @pytest.mark.asyncio
  127. async def test_the_stream_reconnects_a_frozen_camera(ffmpeg):
  128. stream = external_camera.generate_mjpeg_stream("rtsp://192.0.2.80/live", "rtsp", 5)
  129. async with asyncio.timeout(3):
  130. while len(ffmpeg) < 2:
  131. await anext(stream)
  132. await stream.aclose()
  133. assert len(ffmpeg) >= 2