test_external_camera.py 23 KB

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