| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175 |
- """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
|