test_external_camera.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709
  1. """
  2. Tests for the external camera service.
  3. These tests cover pure functions and frame parsing logic.
  4. """
  5. from unittest.mock import patch
  6. import pytest
  7. JPEG_START = b"\xff\xd8"
  8. JPEG_END = b"\xff\xd9"
  9. def _make_jpeg(payload: bytes = b"\x00" * 100) -> bytes:
  10. """Build a synthetic JPEG byte sequence (SOI + payload + EOI)."""
  11. return JPEG_START + payload + JPEG_END
  12. class _FakeMjpegResponse:
  13. """Drop-in for aiohttp's response that drives `iter_chunked` from a fixed
  14. list of byte chunks. Each chunk is yielded once; if the iterator runs out
  15. the response is treated as closed (which is the realistic behaviour for an
  16. MJPEG stream the upstream server has finished). An optional `raise_after`
  17. raises the supplied exception after N chunks to simulate timeout / IO
  18. failure mid-stream."""
  19. def __init__(self, chunks, status=200, raise_after=None, raise_exc=None):
  20. self.status = status
  21. self._chunks = list(chunks)
  22. self._raise_after = raise_after
  23. self._raise_exc = raise_exc
  24. self.headers = {}
  25. self.content = self # the function calls `response.content.iter_chunked(...)`
  26. def iter_chunked(self, _size): # noqa: ARG002 — chunk size is informational
  27. chunks = self._chunks
  28. raise_after = self._raise_after
  29. raise_exc = self._raise_exc
  30. async def _gen():
  31. for i, chunk in enumerate(chunks):
  32. if raise_after is not None and i >= raise_after:
  33. raise raise_exc
  34. yield chunk
  35. return _gen()
  36. async def __aenter__(self):
  37. return self
  38. async def __aexit__(self, *_):
  39. return None
  40. class _FakeMjpegSession:
  41. """Drop-in for aiohttp.ClientSession; `get(url)` returns a pre-baked
  42. `_FakeMjpegResponse`."""
  43. def __init__(self, response):
  44. self._response = response
  45. def get(self, _url, **_kwargs):
  46. return self._response
  47. async def __aenter__(self):
  48. return self
  49. async def __aexit__(self, *_):
  50. return None
  51. def _patch_mjpeg_session(response):
  52. """Patch `aiohttp.ClientSession` inside the external_camera module so the
  53. real `_capture_mjpeg_frame` runs against our fake stream."""
  54. def _factory(*_args, **_kwargs):
  55. return _FakeMjpegSession(response)
  56. return patch("backend.app.services.external_camera.aiohttp.ClientSession", _factory)
  57. class TestCaptureMjpegFrameWarmupSkip:
  58. """Regression for #1177. Many MJPEG sources (notably go2rtc) emit a
  59. warm-up / black frame on the first byte that follows connection accept;
  60. `_capture_mjpeg_frame` must skip past it and return the second frame.
  61. Where the stream ends or times out before a second frame ever arrives the
  62. function falls back to the warm-up frame so callers still get *something*
  63. — returning None there would regress every code path that consumed the
  64. pre-fix behaviour (snapshot UX, plate-detection CV, finish photo,
  65. timelapse, Obico inference)."""
  66. @pytest.mark.asyncio
  67. async def test_skips_warmup_frame_returns_second_frame(self):
  68. # Two frames arriving in two chunks — typical of a steady MJPEG feed.
  69. # Pre-fix this returned `warm`; post-fix returns `live`.
  70. from backend.app.services.external_camera import _capture_mjpeg_frame
  71. warm = _make_jpeg(b"\x10" * 50) # warm-up — encoder hasn't caught up
  72. live = _make_jpeg(b"\x20" * 200) # representative scene
  73. response = _FakeMjpegResponse(chunks=[warm, live])
  74. with _patch_mjpeg_session(response):
  75. frame = await _capture_mjpeg_frame("http://camera.example/stream", timeout=15)
  76. assert frame == live
  77. assert frame != warm
  78. @pytest.mark.asyncio
  79. async def test_two_frames_in_single_chunk_returns_second(self):
  80. # High-FPS sources often pack multiple frames into one chunk delivered
  81. # in a single iteration of `iter_chunked`. The inner while-loop must
  82. # drain every complete frame from the buffer before reading more.
  83. from backend.app.services.external_camera import _capture_mjpeg_frame
  84. warm = _make_jpeg(b"\x10" * 50)
  85. live = _make_jpeg(b"\x20" * 200)
  86. response = _FakeMjpegResponse(chunks=[warm + live])
  87. with _patch_mjpeg_session(response):
  88. frame = await _capture_mjpeg_frame("http://camera.example/stream", timeout=15)
  89. assert frame == live
  90. @pytest.mark.asyncio
  91. async def test_partial_frame_split_across_chunks_assembles_correctly(self):
  92. # Realistic chunking: TCP doesn't respect frame boundaries, so a
  93. # single frame can straddle two chunks. The fix's buffer-trim path
  94. # must still find the SOI / EOI pair across the boundary.
  95. from backend.app.services.external_camera import _capture_mjpeg_frame
  96. warm = _make_jpeg(b"\x10" * 50)
  97. live = _make_jpeg(b"\x20" * 200)
  98. # Split `live` mid-payload
  99. split_at = len(JPEG_START) + 100
  100. chunks = [warm + live[:split_at], live[split_at:]]
  101. response = _FakeMjpegResponse(chunks=chunks)
  102. with _patch_mjpeg_session(response):
  103. frame = await _capture_mjpeg_frame("http://camera.example/stream", timeout=15)
  104. assert frame == live
  105. @pytest.mark.asyncio
  106. async def test_single_frame_stream_falls_back_to_first_frame(self):
  107. # Critical no-regression case. A snapshot-style endpoint that emits
  108. # exactly one frame and closes the connection (or a slow stream that
  109. # only delivers one frame within the timeout window) must still hand
  110. # back that one frame — not None. Pre-fix users on these sources got
  111. # the frame; the warm-up skip would otherwise turn that into None
  112. # silently.
  113. from backend.app.services.external_camera import _capture_mjpeg_frame
  114. only = _make_jpeg(b"\xab" * 80)
  115. response = _FakeMjpegResponse(chunks=[only])
  116. with _patch_mjpeg_session(response):
  117. frame = await _capture_mjpeg_frame("http://camera.example/stream", timeout=15)
  118. assert frame == only
  119. @pytest.mark.asyncio
  120. async def test_timeout_after_first_frame_falls_back_to_first(self):
  121. # Timeout mid-stream — the warm-up frame has already arrived but the
  122. # second hasn't. Same fallback: hand back what we have, never None.
  123. from backend.app.services.external_camera import _capture_mjpeg_frame
  124. warm = _make_jpeg(b"\x10" * 50)
  125. response = _FakeMjpegResponse(
  126. chunks=[warm, b""], # second yield will raise instead
  127. raise_after=1,
  128. raise_exc=TimeoutError(),
  129. )
  130. with _patch_mjpeg_session(response):
  131. frame = await _capture_mjpeg_frame("http://camera.example/stream", timeout=15)
  132. assert frame == warm
  133. @pytest.mark.asyncio
  134. async def test_no_frames_returns_none(self):
  135. # Server replied 200 but emitted zero JPEG bytes before closing —
  136. # there's nothing to return, so None is the correct answer.
  137. from backend.app.services.external_camera import _capture_mjpeg_frame
  138. response = _FakeMjpegResponse(chunks=[b"\x00\x01\x02\x03"])
  139. with _patch_mjpeg_session(response):
  140. frame = await _capture_mjpeg_frame("http://camera.example/stream", timeout=15)
  141. assert frame is None
  142. @pytest.mark.asyncio
  143. async def test_non_200_status_returns_none(self):
  144. # Invariant: a 4xx/5xx is never a valid frame source.
  145. from backend.app.services.external_camera import _capture_mjpeg_frame
  146. response = _FakeMjpegResponse(chunks=[], status=404)
  147. with _patch_mjpeg_session(response):
  148. frame = await _capture_mjpeg_frame("http://camera.example/stream", timeout=15)
  149. assert frame is None
  150. class TestFormatMjpegFrame:
  151. """Tests for MJPEG frame formatting."""
  152. def test_format_mjpeg_frame_basic(self):
  153. """Verify MJPEG frame is formatted correctly with boundary and headers."""
  154. from backend.app.services.external_camera import _format_mjpeg_frame
  155. # Minimal JPEG data (just SOI and EOI markers)
  156. jpeg_data = b"\xff\xd8\xff\xd9"
  157. result = _format_mjpeg_frame(jpeg_data)
  158. # Check boundary
  159. assert result.startswith(b"--frame\r\n")
  160. # Check content type
  161. assert b"Content-Type: image/jpeg\r\n" in result
  162. # Check content length
  163. assert b"Content-Length: 4\r\n" in result
  164. # Check frame data is included
  165. assert jpeg_data in result
  166. # Check ends with CRLF
  167. assert result.endswith(b"\r\n")
  168. def test_format_mjpeg_frame_larger_data(self):
  169. """Verify content length is correct for larger frames."""
  170. from backend.app.services.external_camera import _format_mjpeg_frame
  171. # Simulate a larger JPEG (1000 bytes)
  172. jpeg_data = b"\xff\xd8" + b"\x00" * 996 + b"\xff\xd9"
  173. result = _format_mjpeg_frame(jpeg_data)
  174. assert b"Content-Length: 1000\r\n" in result
  175. class TestGetFfmpegPath:
  176. """Tests for ffmpeg path detection."""
  177. def test_get_ffmpeg_path_from_shutil_which(self):
  178. """Verify ffmpeg found via shutil.which is returned."""
  179. from backend.app.services.external_camera import get_ffmpeg_path
  180. with patch("shutil.which", return_value="/usr/bin/ffmpeg"):
  181. result = get_ffmpeg_path()
  182. assert result == "/usr/bin/ffmpeg"
  183. def test_get_ffmpeg_path_fallback_to_common_paths(self):
  184. """Verify common paths are checked when shutil.which fails."""
  185. from backend.app.services.external_camera import get_ffmpeg_path
  186. with patch("shutil.which", return_value=None), patch("pathlib.Path.exists") as mock_exists:
  187. # First common path exists
  188. mock_exists.return_value = True
  189. result = get_ffmpeg_path()
  190. assert result in ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"]
  191. def test_get_ffmpeg_path_returns_none_when_not_found(self):
  192. """Verify None is returned when ffmpeg not found anywhere."""
  193. from backend.app.services.external_camera import get_ffmpeg_path
  194. with patch("shutil.which", return_value=None), patch("pathlib.Path.exists", return_value=False):
  195. result = get_ffmpeg_path()
  196. assert result is None
  197. class TestJpegFrameExtraction:
  198. """Tests for JPEG frame extraction from buffer."""
  199. def test_extract_single_frame_from_buffer(self):
  200. """Test extracting a complete JPEG frame from buffer."""
  201. # JPEG markers
  202. jpeg_start = b"\xff\xd8"
  203. jpeg_end = b"\xff\xd9"
  204. # Create a buffer with one complete frame
  205. frame_content = b"\x00" * 100
  206. buffer = jpeg_start + frame_content + jpeg_end
  207. # Find frame boundaries
  208. start_idx = buffer.find(jpeg_start)
  209. end_idx = buffer.find(jpeg_end, start_idx + 2)
  210. assert start_idx == 0
  211. assert end_idx == 102
  212. # Extract frame
  213. frame = buffer[start_idx : end_idx + 2]
  214. assert frame == buffer
  215. assert len(frame) == 104
  216. def test_extract_frame_with_leading_garbage(self):
  217. """Test extracting frame when buffer has leading garbage data."""
  218. jpeg_start = b"\xff\xd8"
  219. jpeg_end = b"\xff\xd9"
  220. # Buffer with garbage before the JPEG
  221. garbage = b"\x00\x01\x02\x03"
  222. frame_content = b"\xff" * 50
  223. buffer = garbage + jpeg_start + frame_content + jpeg_end
  224. start_idx = buffer.find(jpeg_start)
  225. assert start_idx == 4 # After garbage
  226. end_idx = buffer.find(jpeg_end, start_idx + 2)
  227. frame = buffer[start_idx : end_idx + 2]
  228. assert frame.startswith(jpeg_start)
  229. assert frame.endswith(jpeg_end)
  230. assert len(frame) == 54 # 2 + 50 + 2
  231. def test_incomplete_frame_detection(self):
  232. """Test detection of incomplete frame (no end marker)."""
  233. jpeg_start = b"\xff\xd8"
  234. # Incomplete buffer - no end marker
  235. buffer = jpeg_start + b"\x00" * 100
  236. start_idx = buffer.find(jpeg_start)
  237. end_idx = buffer.find(b"\xff\xd9", start_idx + 2)
  238. assert start_idx == 0
  239. assert end_idx == -1 # Not found
  240. def test_multiple_frames_in_buffer(self):
  241. """Test extracting first frame when buffer contains multiple frames."""
  242. jpeg_start = b"\xff\xd8"
  243. jpeg_end = b"\xff\xd9"
  244. # Two complete frames
  245. frame1 = jpeg_start + b"\x01" * 10 + jpeg_end
  246. frame2 = jpeg_start + b"\x02" * 20 + jpeg_end
  247. buffer = frame1 + frame2
  248. # Extract first frame
  249. start_idx = buffer.find(jpeg_start)
  250. end_idx = buffer.find(jpeg_end, start_idx + 2)
  251. first_frame = buffer[start_idx : end_idx + 2]
  252. assert first_frame == frame1
  253. assert len(first_frame) == 14
  254. # Remaining buffer should contain second frame
  255. remaining = buffer[end_idx + 2 :]
  256. assert remaining == frame2
  257. class TestCameraTypeValidation:
  258. """Tests for camera type handling."""
  259. @pytest.mark.asyncio
  260. async def test_capture_frame_unknown_type_returns_none(self):
  261. """Verify unknown camera type returns None."""
  262. from backend.app.services.external_camera import capture_frame
  263. result = await capture_frame("http://example.com", "unknown_type")
  264. assert result is None
  265. @pytest.mark.asyncio
  266. async def test_capture_frame_valid_types(self):
  267. """Verify valid camera types are accepted (they may fail but shouldn't error on type)."""
  268. from backend.app.services.external_camera import capture_frame
  269. # These will fail to connect but shouldn't raise type errors
  270. for camera_type in ["mjpeg", "rtsp", "snapshot"]:
  271. # Use a non-routable IP to fail fast
  272. result = await capture_frame("http://192.0.2.1/test", camera_type, timeout=1)
  273. # Should return None (failed connection) not raise exception
  274. assert result is None
  275. class TestSnapshotUrlOverride:
  276. """#1177 follow-up. When ``external_camera_snapshot_url`` is set on the
  277. printer, every single-frame capture (notification thumbnail, finish photo,
  278. timelapse, plate-detect) must route through the plain HTTP-GET path on the
  279. snapshot URL instead of opening the live stream and skipping a warm-up
  280. frame. Sources that expose a dedicated frame endpoint (e.g. go2rtc's
  281. ``/api/frame.jpeg``) reliably return a clean image — the warm-up dance is
  282. only required for sources that don't, and bypassing it removes the
  283. inconsistency the reporter still saw after the warm-up fix landed."""
  284. @pytest.mark.asyncio
  285. async def test_snapshot_override_routes_to_snapshot_path(self):
  286. from unittest.mock import AsyncMock
  287. with (
  288. patch(
  289. "backend.app.services.external_camera._capture_snapshot",
  290. new=AsyncMock(return_value=b"\xff\xd8snapshot\xff\xd9"),
  291. ) as mocked_snapshot,
  292. patch(
  293. "backend.app.services.external_camera._capture_mjpeg_frame",
  294. new=AsyncMock(return_value=b"should-not-be-called"),
  295. ) as mocked_mjpeg,
  296. ):
  297. from backend.app.services.external_camera import capture_frame
  298. result = await capture_frame(
  299. "http://192.168.1.61:1984/api/stream.mjpeg",
  300. "mjpeg",
  301. snapshot_url="http://192.168.1.61:1984/api/frame.jpeg",
  302. )
  303. assert result == b"\xff\xd8snapshot\xff\xd9"
  304. mocked_snapshot.assert_awaited_once()
  305. # First positional arg is the snapshot URL; the live-stream URL is ignored.
  306. assert mocked_snapshot.await_args.args[0] == "http://192.168.1.61:1984/api/frame.jpeg"
  307. mocked_mjpeg.assert_not_awaited()
  308. @pytest.mark.asyncio
  309. async def test_no_snapshot_override_routes_to_camera_type_handler(self):
  310. from unittest.mock import AsyncMock
  311. with (
  312. patch(
  313. "backend.app.services.external_camera._capture_snapshot",
  314. new=AsyncMock(return_value=b"should-not-be-called"),
  315. ) as mocked_snapshot,
  316. patch(
  317. "backend.app.services.external_camera._capture_mjpeg_frame",
  318. new=AsyncMock(return_value=b"\xff\xd8live\xff\xd9"),
  319. ) as mocked_mjpeg,
  320. ):
  321. from backend.app.services.external_camera import capture_frame
  322. result = await capture_frame("http://192.168.1.61:1984/api/stream.mjpeg", "mjpeg")
  323. assert result == b"\xff\xd8live\xff\xd9"
  324. mocked_mjpeg.assert_awaited_once()
  325. mocked_snapshot.assert_not_awaited()
  326. @pytest.mark.asyncio
  327. async def test_empty_string_snapshot_url_treated_as_unset(self):
  328. """Falsy snapshot_url (empty string from a cleared input) must NOT
  329. hijack the live-stream path — the form-cleared input becomes ``None``
  330. in the DB, but a defence-in-depth empty-string guard means a stale
  331. config row still uses the live stream rather than firing GET ''."""
  332. from unittest.mock import AsyncMock
  333. with (
  334. patch(
  335. "backend.app.services.external_camera._capture_snapshot",
  336. new=AsyncMock(return_value=b"should-not-be-called"),
  337. ) as mocked_snapshot,
  338. patch(
  339. "backend.app.services.external_camera._capture_mjpeg_frame",
  340. new=AsyncMock(return_value=b"\xff\xd8live\xff\xd9"),
  341. ) as mocked_mjpeg,
  342. ):
  343. from backend.app.services.external_camera import capture_frame
  344. result = await capture_frame(
  345. "http://192.168.1.61:1984/api/stream.mjpeg",
  346. "mjpeg",
  347. snapshot_url="",
  348. )
  349. assert result == b"\xff\xd8live\xff\xd9"
  350. mocked_mjpeg.assert_awaited_once()
  351. mocked_snapshot.assert_not_awaited()
  352. @pytest.mark.asyncio
  353. async def test_snapshot_override_honours_ssrf_guard(self):
  354. """The override goes through ``_capture_snapshot`` which already
  355. sanitises the URL — link-local / metadata / blocked-host targets
  356. return None instead of being fetched."""
  357. from backend.app.services.external_camera import capture_frame
  358. result = await capture_frame(
  359. "http://192.168.1.61:1984/api/stream.mjpeg",
  360. "mjpeg",
  361. snapshot_url="http://169.254.169.254/latest/meta-data/",
  362. )
  363. assert result is None
  364. @pytest.mark.asyncio
  365. async def test_snapshot_override_works_for_rtsp_and_usb_camera_types(self):
  366. """The override is camera-type agnostic: a user with an RTSP or USB
  367. stream paired with a separate HTTP snapshot endpoint (e.g. go2rtc
  368. feeding a USB cam, exposing both /api/stream.mjpeg and
  369. /api/frame.jpeg) gets clean snapshots without spinning up ffmpeg."""
  370. from unittest.mock import AsyncMock
  371. for camera_type in ("rtsp", "usb"):
  372. with (
  373. patch(
  374. "backend.app.services.external_camera._capture_snapshot",
  375. new=AsyncMock(return_value=b"\xff\xd8snap\xff\xd9"),
  376. ) as mocked_snapshot,
  377. patch(
  378. "backend.app.services.external_camera._capture_rtsp_frame",
  379. new=AsyncMock(return_value=b"should-not-be-called"),
  380. ) as mocked_rtsp,
  381. patch(
  382. "backend.app.services.external_camera._capture_usb_frame",
  383. new=AsyncMock(return_value=b"should-not-be-called"),
  384. ) as mocked_usb,
  385. ):
  386. from backend.app.services.external_camera import capture_frame
  387. result = await capture_frame(
  388. "rtsp://printer/stream" if camera_type == "rtsp" else "/dev/video0",
  389. camera_type,
  390. snapshot_url="http://192.168.1.61:1984/api/frame.jpeg",
  391. )
  392. assert result == b"\xff\xd8snap\xff\xd9", f"camera_type={camera_type}"
  393. mocked_snapshot.assert_awaited_once()
  394. mocked_rtsp.assert_not_awaited()
  395. mocked_usb.assert_not_awaited()
  396. class TestRtspUrlHandling:
  397. """Tests for RTSP/RTSPS URL handling."""
  398. def test_rtsps_url_detection(self):
  399. """Verify rtsps:// and rtsp:// URL schemes are distinct."""
  400. url_rtsps = "rtsps://user:pass@192.168.1.1:554/stream"
  401. url_rtsp = "rtsp://user:pass@192.168.1.1:554/stream"
  402. assert url_rtsps.startswith("rtsps://")
  403. assert not url_rtsp.startswith("rtsps://")
  404. assert url_rtsp.startswith("rtsp://")
  405. def test_ffmpeg_handles_both_rtsp_and_rtsps(self):
  406. """Verify ffmpeg command structure handles both URL schemes identically.
  407. ffmpeg automatically handles TLS for rtsps:// URLs, so no special
  408. flags are needed - both URL schemes use the same command structure.
  409. """
  410. # Both URL types should use the same basic ffmpeg options
  411. base_cmd = [
  412. "ffmpeg",
  413. "-rtsp_transport",
  414. "tcp",
  415. "-i",
  416. ]
  417. rtsp_url = "rtsp://user:pass@192.168.1.1:554/stream"
  418. rtsps_url = "rtsps://user:pass@192.168.1.1:554/stream"
  419. # Command structure is identical for both
  420. cmd_rtsp = base_cmd + [rtsp_url]
  421. cmd_rtsps = base_cmd + [rtsps_url]
  422. # Only the URL differs
  423. assert cmd_rtsp[:-1] == cmd_rtsps[:-1]
  424. assert cmd_rtsp[-1] != cmd_rtsps[-1]
  425. class TestUsbCameraHandling:
  426. """Tests for USB camera support."""
  427. def test_list_usb_cameras_returns_list(self):
  428. """Verify list_usb_cameras returns a list (may be empty if no cameras)."""
  429. from backend.app.services.external_camera import list_usb_cameras
  430. result = list_usb_cameras()
  431. assert isinstance(result, list)
  432. def test_list_usb_cameras_dict_structure(self):
  433. """Verify each camera entry has expected fields."""
  434. from backend.app.services.external_camera import list_usb_cameras
  435. result = list_usb_cameras()
  436. for camera in result:
  437. assert "device" in camera
  438. assert "name" in camera
  439. assert camera["device"].startswith("/dev/video")
  440. @pytest.mark.asyncio
  441. async def test_capture_frame_usb_type_accepted(self):
  442. """Verify 'usb' camera type is accepted."""
  443. from backend.app.services.external_camera import capture_frame
  444. # Non-existent device should fail gracefully
  445. result = await capture_frame("/dev/video999", "usb", timeout=1)
  446. assert result is None
  447. @pytest.mark.asyncio
  448. async def test_capture_frame_usb_invalid_device_path(self):
  449. """Verify invalid USB device paths are rejected."""
  450. from backend.app.services.external_camera import capture_frame
  451. # Invalid device path (not /dev/video*)
  452. result = await capture_frame("/dev/sda1", "usb", timeout=1)
  453. assert result is None
  454. result = await capture_frame("http://example.com", "usb", timeout=1)
  455. assert result is None
  456. def _encode_image(ext: str) -> bytes:
  457. """Encode a small solid test image to the given container (.png/.webp/.jpg)."""
  458. import cv2
  459. import numpy as np
  460. img = np.zeros((16, 24, 3), dtype=np.uint8)
  461. img[:, :12] = (0, 0, 255) # half red so the frame isn't uniformly black
  462. ok, buf = cv2.imencode(ext, img)
  463. assert ok, f"failed to encode {ext}"
  464. return buf.tobytes()
  465. def _fake_snapshot_session(body: bytes, status: int = 200):
  466. """Build an aiohttp.ClientSession stand-in whose GET yields `body`.
  467. Matches the `async with ClientSession(...) as session, session.get(url) as
  468. response` usage inside `_capture_snapshot`.
  469. """
  470. class _Resp:
  471. def __init__(self):
  472. self.status = status
  473. self.headers = {}
  474. async def __aenter__(self):
  475. return self
  476. async def __aexit__(self, *a):
  477. return False
  478. async def read(self):
  479. return body
  480. class _Session:
  481. def __init__(self, *a, **k):
  482. pass
  483. async def __aenter__(self):
  484. return self
  485. async def __aexit__(self, *a):
  486. return False
  487. def get(self, _url, **_kwargs):
  488. return _Resp()
  489. return _Session
  490. class TestSnapshotTranscode:
  491. """Regression for #1902. Snapshot endpoints that serve PNG/WebP (not JPEG)
  492. broke the browser MJPEG stream, because every multipart part is hard-labelled
  493. ``Content-Type: image/jpeg`` — the browser rejected the non-JPEG payload and
  494. dropped the whole stream ("connection lost"). ``_capture_snapshot`` now
  495. transcodes non-JPEG stills to JPEG; only genuinely undecodable payloads fall
  496. through to the raw bytes (unchanged last-resort behaviour)."""
  497. def test_transcode_png_to_jpeg(self):
  498. from backend.app.services.external_camera import _transcode_to_jpeg
  499. png = _encode_image(".png")
  500. assert not png.startswith(JPEG_START) # sanity: input really is PNG
  501. out = _transcode_to_jpeg(png)
  502. assert out is not None and out.startswith(JPEG_START)
  503. def test_transcode_webp_to_jpeg(self):
  504. from backend.app.services.external_camera import _transcode_to_jpeg
  505. webp = _encode_image(".webp")
  506. assert not webp.startswith(JPEG_START)
  507. out = _transcode_to_jpeg(webp)
  508. assert out is not None and out.startswith(JPEG_START)
  509. def test_transcode_returns_none_for_non_image(self):
  510. """HTML error pages / auth redirects / empty bodies aren't images —
  511. transcode returns None so the caller can log and fall back."""
  512. from backend.app.services.external_camera import _transcode_to_jpeg
  513. assert _transcode_to_jpeg(b"<html><body>404 Not Found</body></html>") is None
  514. assert _transcode_to_jpeg(b"") is None
  515. @pytest.mark.asyncio
  516. async def test_capture_snapshot_transcodes_png_response(self):
  517. """The reported case: a snapshot URL returning PNG yields JPEG bytes."""
  518. from backend.app.services import external_camera as ec
  519. png = _encode_image(".png")
  520. with patch.object(ec.aiohttp, "ClientSession", _fake_snapshot_session(png)):
  521. out = await ec._capture_snapshot("http://192.168.50.50/snapshot.png", 10)
  522. assert out is not None and out.startswith(JPEG_START)
  523. @pytest.mark.asyncio
  524. async def test_capture_snapshot_jpeg_passthrough_unchanged(self):
  525. """A JPEG snapshot must be returned byte-for-byte (fast path, no
  526. re-encode) so we don't degrade quality or waste CPU on JPEG cameras."""
  527. from backend.app.services import external_camera as ec
  528. jpeg = _encode_image(".jpg")
  529. assert jpeg.startswith(JPEG_START)
  530. with patch.object(ec.aiohttp, "ClientSession", _fake_snapshot_session(jpeg)):
  531. out = await ec._capture_snapshot("http://192.168.50.50/snapshot.jpg", 10)
  532. assert out == jpeg # identical object bytes — proves no transcode ran
  533. @pytest.mark.asyncio
  534. async def test_capture_snapshot_non_image_is_not_passed_on(self):
  535. """An undecodable (non-image) response is no frame: whatever a URL
  536. returns would otherwise reach the viewer of the camera stream."""
  537. from backend.app.services import external_camera as ec
  538. html = b"<html><body>unauthorized</body></html>"
  539. with patch.object(ec.aiohttp, "ClientSession", _fake_snapshot_session(html)):
  540. out = await ec._capture_snapshot("http://192.168.50.50/snapshot", 10)
  541. assert out is None