|
|
@@ -0,0 +1,175 @@
|
|
|
+"""A frozen external RTSP camera is reconnected (RC-C1).
|
|
|
+
|
|
|
+ffmpeg can go on repeating its last frame after the camera has stopped sending
|
|
|
+(`-r fps` duplicates it), so frame counting sees a healthy stream: status says
|
|
|
+live, and timelapse, finish photos and Obico reuse a picture that stopped
|
|
|
+hours ago. The printer's own RTSP stream restarts ffmpeg after 20 s of
|
|
|
+byte-identical frames (#3218); the external one now does the same, with the
|
|
|
+same still-scene rule for a camera that really shows an unchanging picture.
|
|
|
+"""
|
|
|
+
|
|
|
+import asyncio
|
|
|
+from unittest.mock import AsyncMock, MagicMock
|
|
|
+
|
|
|
+import pytest
|
|
|
+
|
|
|
+from backend.app.services import external_camera
|
|
|
+from backend.app.services.external_camera import _FreezeWatch
|
|
|
+
|
|
|
+A = b"\xff\xd8" + b"a" * 200 + b"\xff\xd9"
|
|
|
+B = b"\xff\xd8" + b"b" * 200 + b"\xff\xd9"
|
|
|
+
|
|
|
+
|
|
|
+class TestTheFreezeWatch:
|
|
|
+ def test_a_moving_picture_never_freezes(self):
|
|
|
+ watch = _FreezeWatch()
|
|
|
+ watch.start_session()
|
|
|
+ assert not any(watch.frozen(A if i % 2 else B, float(i)) for i in range(100))
|
|
|
+
|
|
|
+ def test_the_same_frame_for_twenty_seconds_is_frozen(self):
|
|
|
+ watch = _FreezeWatch()
|
|
|
+ watch.start_session()
|
|
|
+ assert not watch.frozen(A, 0.0)
|
|
|
+ assert not watch.frozen(A, 19.0)
|
|
|
+ assert watch.frozen(A, 21.0)
|
|
|
+
|
|
|
+ def test_the_same_picture_after_a_restart_is_a_still_scene(self):
|
|
|
+ """A dark, idle chamber can encode to the same frame every time: then
|
|
|
+ the stream only re-checks every few minutes."""
|
|
|
+ watch = _FreezeWatch()
|
|
|
+ watch.start_session()
|
|
|
+ watch.frozen(A, 0.0)
|
|
|
+ assert watch.frozen(A, 21.0)
|
|
|
+
|
|
|
+ watch.start_session()
|
|
|
+ assert not watch.frozen(A, 22.0)
|
|
|
+ assert not watch.frozen(A, 22.0 + 299.0)
|
|
|
+ assert watch.frozen(A, 22.0 + 301.0)
|
|
|
+
|
|
|
+ def test_a_changed_picture_ends_the_still_scene(self):
|
|
|
+ watch = _FreezeWatch()
|
|
|
+ watch.start_session()
|
|
|
+ watch.frozen(A, 0.0)
|
|
|
+ watch.frozen(A, 21.0)
|
|
|
+ watch.start_session()
|
|
|
+ watch.frozen(A, 22.0)
|
|
|
+
|
|
|
+ watch.frozen(B, 23.0)
|
|
|
+
|
|
|
+ assert not watch.frozen(B, 40.0)
|
|
|
+ assert watch.frozen(B, 44.0)
|
|
|
+
|
|
|
+
|
|
|
+class TestASlowOrQuietCamera:
|
|
|
+ """Unlike a Bambu camera, an external one can legitimately repeat frames for
|
|
|
+ longer than 20 s: a source slower than one picture per 20 s (ffmpeg's -r
|
|
|
+ repeats each until the next), or a surveillance camera's smart codec on a
|
|
|
+ static night scene. Restarting those every 20 s would break a camera that
|
|
|
+ worked; the limit grows to three times the longest gap seen between real
|
|
|
+ changes, up to the still-scene interval."""
|
|
|
+
|
|
|
+ def test_a_slow_source_is_restarted_once_then_left_alone(self):
|
|
|
+ watch = _FreezeWatch()
|
|
|
+ watch.start_session()
|
|
|
+ watch.frozen(A, 0.0)
|
|
|
+ assert watch.frozen(A, 21.0) # looked frozen: no way to know yet
|
|
|
+
|
|
|
+ # After the restart the picture is a new one: it had not frozen, it is slow.
|
|
|
+ watch.start_session()
|
|
|
+ assert not watch.frozen(B, 23.0)
|
|
|
+ assert not watch.frozen(B, 23.0 + 60.0) # 3 x the 21 s gap it showed
|
|
|
+ assert watch.frozen(B, 23.0 + 64.0)
|
|
|
+
|
|
|
+ def test_a_gap_seen_within_a_session_raises_the_limit(self):
|
|
|
+ watch = _FreezeWatch()
|
|
|
+ watch.start_session()
|
|
|
+ watch.frozen(A, 0.0)
|
|
|
+ watch.frozen(B, 15.0) # a 15 s gap between real pictures
|
|
|
+ assert not watch.frozen(B, 15.0 + 44.0)
|
|
|
+ assert watch.frozen(B, 15.0 + 46.0)
|
|
|
+
|
|
|
+ def test_the_limit_never_exceeds_the_still_scene_interval(self):
|
|
|
+ watch = _FreezeWatch()
|
|
|
+ watch.start_session()
|
|
|
+ watch.frozen(A, 0.0)
|
|
|
+ watch.frozen(B, 200.0) # 3 x 200 s would be 10 minutes
|
|
|
+ assert watch.frozen(B, 200.0 + 301.0)
|
|
|
+
|
|
|
+
|
|
|
+class _Ffmpeg:
|
|
|
+ """An ffmpeg that repeats one frame forever, as after a dead upstream."""
|
|
|
+
|
|
|
+ def __init__(self, frame: bytes) -> None:
|
|
|
+ self.pid = 53001
|
|
|
+ self.returncode = None
|
|
|
+ self.stdout = MagicMock()
|
|
|
+ self.stderr = asyncio.StreamReader()
|
|
|
+ self.stderr.feed_eof()
|
|
|
+ self._frame = frame
|
|
|
+
|
|
|
+ async def read(_n):
|
|
|
+ await asyncio.sleep(0.005)
|
|
|
+ return self._frame
|
|
|
+
|
|
|
+ self.stdout.read = read
|
|
|
+
|
|
|
+ def terminate(self) -> None:
|
|
|
+ self.returncode = 0
|
|
|
+
|
|
|
+ def kill(self) -> None:
|
|
|
+ self.returncode = -9
|
|
|
+
|
|
|
+ async def wait(self) -> int:
|
|
|
+ return 0
|
|
|
+
|
|
|
+
|
|
|
+@pytest.fixture
|
|
|
+def ffmpeg(monkeypatch):
|
|
|
+ spawned: list[_Ffmpeg] = []
|
|
|
+
|
|
|
+ async def spawn(*_args, **_kwargs):
|
|
|
+ proc = _Ffmpeg(A)
|
|
|
+ spawned.append(proc)
|
|
|
+ return proc
|
|
|
+
|
|
|
+ monkeypatch.setattr(external_camera, "get_ffmpeg_path", lambda: "/usr/bin/ffmpeg")
|
|
|
+ monkeypatch.setattr(external_camera, "read_ffmpeg_log_until_open", AsyncMock(return_value=("open", b"")))
|
|
|
+ monkeypatch.setattr(external_camera, "watch_ffmpeg_log", AsyncMock(return_value=None))
|
|
|
+ monkeypatch.setattr("asyncio.create_subprocess_exec", spawn)
|
|
|
+ monkeypatch.setattr(external_camera, "_EXTERNAL_RTSP_FROZEN_SECONDS", 0.1)
|
|
|
+ monkeypatch.setattr(external_camera.asyncio, "sleep", _fast_sleep(asyncio.sleep))
|
|
|
+ return spawned
|
|
|
+
|
|
|
+
|
|
|
+def _fast_sleep(real_sleep):
|
|
|
+ async def sleep(delay, *args, **kwargs):
|
|
|
+ # The reconnect pause; the frame pacing stays as it is.
|
|
|
+ return await real_sleep(min(delay, 0.01), *args, **kwargs)
|
|
|
+
|
|
|
+ return sleep
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_a_frozen_session_ends(ffmpeg):
|
|
|
+ session = external_camera._stream_rtsp("rtsp://192.0.2.80/live", 5, freeze=_FreezeWatch())
|
|
|
+
|
|
|
+ frames = 0
|
|
|
+ async with asyncio.timeout(3):
|
|
|
+ async for _ in session:
|
|
|
+ frames += 1
|
|
|
+
|
|
|
+ assert frames > 1
|
|
|
+ # It ended on the freeze, not on its way out: ffmpeg was still running.
|
|
|
+ assert ffmpeg[0].returncode is not None
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_the_stream_reconnects_a_frozen_camera(ffmpeg):
|
|
|
+ stream = external_camera.generate_mjpeg_stream("rtsp://192.0.2.80/live", "rtsp", 5)
|
|
|
+
|
|
|
+ async with asyncio.timeout(3):
|
|
|
+ while len(ffmpeg) < 2:
|
|
|
+ await anext(stream)
|
|
|
+ await stream.aclose()
|
|
|
+
|
|
|
+ assert len(ffmpeg) >= 2
|