external_camera.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032
  1. """External camera service.
  2. Supports MJPEG streams, RTSP streams (via ffmpeg), HTTP snapshot URLs, and USB cameras.
  3. Security Note: This service intentionally makes requests to user-configured camera URLs.
  4. This is necessary functionality for external camera integration. URLs are validated
  5. to ensure they are well-formed before use.
  6. """
  7. import asyncio
  8. import logging
  9. import re
  10. import shutil
  11. from collections.abc import AsyncGenerator, Callable
  12. from pathlib import Path
  13. from urllib.parse import urlparse
  14. import aiohttp
  15. from backend.app.core.logging_filters import redact_url_credentials
  16. logger = logging.getLogger(__name__)
  17. def _sanitize_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "https", "rtsp")) -> str | None:
  18. """Validate and sanitize camera URL, returning a safe reconstructed URL.
  19. This validates that the URL is well-formed, uses an allowed scheme,
  20. does not target cloud metadata services, and returns a reconstructed
  21. URL from validated components.
  22. Note: This intentionally allows user-provided URLs as that is the
  23. purpose of external camera configuration. Local network IPs are
  24. allowed since cameras are typically on the same LAN.
  25. Args:
  26. url: URL to validate and sanitize
  27. allowed_schemes: Tuple of allowed URL schemes
  28. Returns:
  29. Sanitized URL string if valid, None otherwise
  30. """
  31. try:
  32. parsed = urlparse(url)
  33. if not parsed.scheme or not parsed.netloc:
  34. return None
  35. # Validate scheme against allowlist
  36. scheme = parsed.scheme.lower()
  37. if scheme not in allowed_schemes:
  38. return None
  39. # Block cloud metadata service endpoints (SSRF mitigation)
  40. # These are dangerous destinations that should never be accessed
  41. hostname = parsed.hostname or ""
  42. hostname_lower = hostname.lower()
  43. blocked_hosts = (
  44. "169.254.169.254", # AWS/GCP/Azure metadata
  45. "metadata.google.internal", # GCP metadata
  46. "metadata.google",
  47. "localhost", # Block localhost to prevent internal service access
  48. "127.0.0.1",
  49. "::1",
  50. "0.0.0.0", # nosec B104
  51. )
  52. if hostname_lower in blocked_hosts:
  53. logger.warning("Blocked camera URL targeting restricted host: %s", hostname)
  54. return None
  55. # Block link-local addresses (169.254.x.x)
  56. if hostname.startswith("169.254."):
  57. logger.warning("Blocked camera URL targeting link-local address: %s", hostname)
  58. return None
  59. # Reconstruct URL from validated components to break taint chain
  60. # This creates a new string from validated parts
  61. port_str = f":{parsed.port}" if parsed.port else ""
  62. path = parsed.path or ""
  63. query = f"?{parsed.query}" if parsed.query else ""
  64. fragment = f"#{parsed.fragment}" if parsed.fragment else ""
  65. # Build sanitized URL from validated components
  66. sanitized = f"{scheme}://{hostname}{port_str}{path}{query}{fragment}"
  67. return sanitized
  68. except ValueError:
  69. return None
  70. def _validate_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "https", "rtsp")) -> bool:
  71. """Validate camera URL format (legacy wrapper).
  72. Args:
  73. url: URL to validate
  74. allowed_schemes: Tuple of allowed URL schemes
  75. Returns:
  76. True if URL is valid, False otherwise
  77. """
  78. return _sanitize_camera_url(url, allowed_schemes) is not None
  79. def list_usb_cameras() -> list[dict]:
  80. """List available USB cameras (V4L2 devices on Linux).
  81. Returns:
  82. List of dicts with {device: str, name: str, capabilities: list}
  83. """
  84. cameras = []
  85. video_devices = sorted(Path("/dev").glob("video*"))
  86. for device in video_devices:
  87. device_path = str(device)
  88. info = {"device": device_path, "name": device.name, "capabilities": []}
  89. # Try to get device info via v4l2-ctl
  90. v4l2_ctl = shutil.which("v4l2-ctl")
  91. if v4l2_ctl:
  92. import subprocess
  93. try:
  94. result = subprocess.run(
  95. [v4l2_ctl, "-d", device_path, "--info"],
  96. capture_output=True,
  97. text=True,
  98. timeout=5,
  99. )
  100. if result.returncode == 0:
  101. # Parse device name from output
  102. for line in result.stdout.splitlines():
  103. if "Card type" in line:
  104. info["name"] = line.split(":", 1)[1].strip()
  105. elif "Driver name" in line:
  106. info["driver"] = line.split(":", 1)[1].strip()
  107. # Check if device supports video capture
  108. result = subprocess.run(
  109. [v4l2_ctl, "-d", device_path, "--list-formats"],
  110. capture_output=True,
  111. text=True,
  112. timeout=5,
  113. )
  114. if result.returncode == 0 and result.stdout.strip():
  115. info["capabilities"].append("capture")
  116. # Parse available formats
  117. formats = re.findall(r"'(\w+)'", result.stdout)
  118. info["formats"] = list(set(formats))
  119. except (subprocess.TimeoutExpired, Exception) as e:
  120. logger.debug("v4l2-ctl failed for %s: %s", device_path, e)
  121. # Only include devices that look like video capture devices
  122. # Skip metadata devices (typically odd numbered like video1, video3)
  123. try:
  124. device_num = int(device.name.replace("video", ""))
  125. # Even numbered devices are usually capture, odd are metadata
  126. # But also check if we got capabilities
  127. if info.get("capabilities") or device_num % 2 == 0:
  128. cameras.append(info)
  129. except ValueError:
  130. cameras.append(info)
  131. return cameras
  132. def get_ffmpeg_path() -> str | None:
  133. """Get the path to ffmpeg executable."""
  134. # Try shutil.which first
  135. path = shutil.which("ffmpeg")
  136. if path:
  137. return path
  138. # Check common locations (systemd services may have limited PATH)
  139. for common_path in ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"]:
  140. if Path(common_path).exists():
  141. return common_path
  142. return None
  143. async def capture_frame(
  144. url: str,
  145. camera_type: str,
  146. timeout: int = 15,
  147. snapshot_url: str | None = None,
  148. ) -> bytes | None:
  149. """Capture single frame from external camera.
  150. Args:
  151. url: Live-stream URL (MJPEG stream, RTSP URL, HTTP snapshot URL, or USB device path).
  152. camera_type: "mjpeg", "rtsp", "snapshot", or "usb".
  153. timeout: Connection timeout in seconds.
  154. snapshot_url: Optional override for single-frame capture. When set, fetched
  155. via plain HTTP GET regardless of `camera_type`. Bypasses MJPEG warm-up
  156. handling on sources that expose a dedicated frame endpoint (e.g. go2rtc's
  157. `/api/frame.jpeg` reliably returns a clean image while the MJPEG stream's
  158. first frame is often the encoder's stale keyframe). #1177.
  159. Returns:
  160. JPEG bytes or None on failure
  161. """
  162. if snapshot_url:
  163. # Redact before truncating — slicing first can cut the URL short of the
  164. # ``@`` the pattern anchors on and leave the password in the log.
  165. logger.debug("capture_frame using snapshot override url=%s...", redact_url_credentials(snapshot_url)[:50])
  166. return await _capture_snapshot(snapshot_url, timeout)
  167. logger.debug(
  168. "capture_frame called: type=%s, url=%s...",
  169. camera_type,
  170. redact_url_credentials(url)[:50] if url else "None",
  171. )
  172. if camera_type == "mjpeg":
  173. return await _capture_mjpeg_frame(url, timeout)
  174. elif camera_type == "rtsp":
  175. return await _capture_rtsp_frame(url, timeout)
  176. elif camera_type == "snapshot":
  177. return await _capture_snapshot(url, timeout)
  178. elif camera_type == "usb":
  179. return await _capture_usb_frame(url, timeout)
  180. else:
  181. logger.warning("Unknown camera type: %s", camera_type)
  182. return None
  183. async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
  184. """Capture frame from USB camera using ffmpeg."""
  185. ffmpeg = get_ffmpeg_path()
  186. if not ffmpeg:
  187. logger.error("ffmpeg not found - required for USB camera capture")
  188. return None
  189. # Validate device path - must be /dev/videoN format where N is 0-99
  190. # This prevents path traversal by using a strict allowlist approach
  191. import re as regex_module
  192. device_match = regex_module.match(r"^/dev/video(\d{1,2})$", device)
  193. if not device_match:
  194. logger.error("Invalid USB device path format: %s", device)
  195. return None
  196. # Convert to integer to break taint chain - integers cannot contain path traversal
  197. # lgtm[py/path-injection] - device_num is validated integer 0-99
  198. device_num = int(device_match.group(1)) # Safe: regex guarantees 1-2 digits
  199. if device_num > 99:
  200. logger.error("USB device number out of range: %s", device_num)
  201. return None
  202. # Construct safe path from validated integer (completely untainted)
  203. safe_device_path = Path(f"/dev/video{device_num}") # lgtm[py/path-injection]
  204. if not safe_device_path.exists():
  205. logger.error("USB device does not exist: %s", safe_device_path)
  206. return None
  207. # Use the safe path for ffmpeg - this is a hardcoded /dev/videoN path
  208. device = str(safe_device_path) # lgtm[py/path-injection]
  209. # Use ffmpeg to grab a single frame from USB camera
  210. cmd = [
  211. ffmpeg,
  212. "-f",
  213. "v4l2",
  214. "-i",
  215. device,
  216. "-frames:v",
  217. "1",
  218. "-f",
  219. "image2pipe",
  220. "-vcodec",
  221. "mjpeg",
  222. "-q:v",
  223. "2",
  224. "-",
  225. ]
  226. try:
  227. logger.debug("Running USB capture: %s", " ".join(cmd))
  228. process = await asyncio.create_subprocess_exec(
  229. *cmd,
  230. stdout=asyncio.subprocess.PIPE,
  231. stderr=asyncio.subprocess.PIPE,
  232. )
  233. stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
  234. if process.returncode != 0:
  235. logger.error("ffmpeg USB capture failed: %s", stderr.decode()[:200])
  236. return None
  237. if not stdout or len(stdout) < 100:
  238. logger.error("ffmpeg returned empty or too small frame from USB camera")
  239. return None
  240. return stdout
  241. except TimeoutError:
  242. logger.warning("USB frame capture timed out after %ss", timeout)
  243. if process:
  244. process.kill()
  245. return None
  246. except OSError as e:
  247. logger.error("USB frame capture failed: %s", e)
  248. return None
  249. async def _capture_mjpeg_frame(url: str, timeout: int) -> bytes | None:
  250. """Extract a single representative frame from an MJPEG stream.
  251. Many MJPEG sources — go2rtc most notably (#1177), and several IP cameras —
  252. emit a "warm-up" frame on the byte that follows connection accept: usually
  253. the last keyframe held in the encoder, which is often black or stale until
  254. the encoder catches up to live content. To return a frame that's actually
  255. representative of the scene we read past the first frame and return the
  256. second; if the connection closes / times out / hits the buffer cap before
  257. a second frame ever arrives we fall back to the first so callers still
  258. get *something* (better than degrading slow / single-frame streams to None,
  259. which would regress every code path that consumed pre-fix behaviour).
  260. Note: this function intentionally makes requests to user-configured URLs.
  261. External camera support requires connecting to user-specified camera
  262. endpoints. URL is sanitized and dangerous destinations are blocked.
  263. """
  264. safe_url = _sanitize_camera_url(url, ("http", "https"))
  265. if not safe_url:
  266. logger.error("Invalid MJPEG URL format: %s...", redact_url_credentials(url)[:50])
  267. return None
  268. jpeg_start = b"\xff\xd8"
  269. jpeg_end = b"\xff\xd9"
  270. first_frame: bytes | None = None # warm-up frame; fallback if no second arrives
  271. buffer = b""
  272. try:
  273. async with (
  274. aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session,
  275. session.get(safe_url) as response,
  276. ):
  277. if response.status != 200:
  278. logger.error("MJPEG stream returned status %s", response.status)
  279. return None
  280. async for chunk in response.content.iter_chunked(8192):
  281. buffer += chunk
  282. # A single chunk can carry multiple frames (e.g. high-FPS sources)
  283. # or a partial frame. Drain every complete frame we already have
  284. # before pulling the next chunk.
  285. while True:
  286. start_idx = buffer.find(jpeg_start)
  287. if start_idx == -1:
  288. # No frame start yet — drop trailing garbage, keep waiting.
  289. break
  290. end_idx = buffer.find(jpeg_end, start_idx + 2)
  291. if end_idx == -1:
  292. # Partial frame; trim already-discarded prefix so the
  293. # buffer stays bounded across long-running streams.
  294. if start_idx > 0:
  295. buffer = buffer[start_idx:]
  296. break
  297. frame = buffer[start_idx : end_idx + 2]
  298. buffer = buffer[end_idx + 2 :]
  299. if first_frame is None:
  300. first_frame = frame # warm-up; keep but don't return yet
  301. continue
  302. return frame # representative second frame
  303. if len(buffer) > 5 * 1024 * 1024: # 5MB limit
  304. logger.warning("MJPEG buffer exceeded 5MB without finding frame")
  305. break # exit chunk loop, fall through to first_frame fallback
  306. except TimeoutError:
  307. logger.warning("MJPEG frame capture timed out after %ss", timeout)
  308. except (aiohttp.ClientError, OSError) as e:
  309. logger.error("MJPEG frame capture failed: %s", e)
  310. # Stream ended / timed out / buffer cap before a second frame arrived.
  311. # Return whatever warm-up frame we managed to read; better an iffy frame
  312. # than None for callers that need *some* image (snapshot UX, plate-detect
  313. # CV, finish photo). None only if no frame ever arrived at all.
  314. return first_frame
  315. async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
  316. """Capture frame from RTSP using ffmpeg.
  317. For rtsps:// URLs, a local TLS proxy is used to avoid GnuTLS issues.
  318. """
  319. ffmpeg = get_ffmpeg_path()
  320. if not ffmpeg:
  321. logger.error("ffmpeg not found - required for RTSP capture")
  322. return None
  323. # If rtsps://, use TLS proxy
  324. proxy_server = None
  325. effective_url = url
  326. if url.lower().startswith("rtsps://"):
  327. try:
  328. from urllib.parse import urlparse
  329. from backend.app.services.camera import create_tls_proxy
  330. parsed = urlparse(url)
  331. target_port = parsed.port or 322
  332. proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
  333. userinfo = ""
  334. if parsed.username:
  335. userinfo = parsed.username
  336. if parsed.password:
  337. userinfo += f":{parsed.password}"
  338. userinfo += "@"
  339. effective_url = f"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}"
  340. if parsed.query:
  341. effective_url += f"?{parsed.query}"
  342. except Exception as e:
  343. logger.warning("Failed to create TLS proxy for RTSP capture, falling back: %s", e)
  344. effective_url = url
  345. cmd = [
  346. ffmpeg,
  347. "-rtsp_transport",
  348. "tcp",
  349. "-i",
  350. effective_url,
  351. "-frames:v",
  352. "1",
  353. "-f",
  354. "image2pipe",
  355. "-vcodec",
  356. "mjpeg",
  357. "-q:v",
  358. "2",
  359. "-",
  360. ]
  361. try:
  362. logger.debug("Running ffmpeg RTSP capture...")
  363. process = await asyncio.create_subprocess_exec(
  364. *cmd,
  365. stdout=asyncio.subprocess.PIPE,
  366. stderr=asyncio.subprocess.PIPE,
  367. )
  368. stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
  369. logger.debug(
  370. "ffmpeg returned: code=%s, stdout=%s bytes, stderr=%s bytes",
  371. process.returncode,
  372. len(stdout),
  373. len(stderr),
  374. )
  375. if process.returncode != 0:
  376. # ffmpeg echoes the RTSP input URL, which carries the camera password.
  377. logger.error("ffmpeg RTSP capture failed: %s", redact_url_credentials(stderr.decode())[:200])
  378. return None
  379. if not stdout or len(stdout) < 100:
  380. logger.error("ffmpeg returned empty or too small frame")
  381. return None
  382. return stdout
  383. except TimeoutError:
  384. logger.warning("RTSP frame capture timed out after %ss", timeout)
  385. if process:
  386. process.kill()
  387. return None
  388. except OSError as e:
  389. logger.error("RTSP frame capture failed: %s", e)
  390. return None
  391. finally:
  392. if proxy_server:
  393. proxy_server.close()
  394. await proxy_server.wait_closed()
  395. def _transcode_to_jpeg(data: bytes) -> bytes | None:
  396. """Decode an arbitrary still image (PNG/WebP/BMP/GIF/...) and re-encode as JPEG.
  397. Some camera/proxy snapshot endpoints serve stills as PNG or WebP rather than
  398. JPEG. A browser opened directly at the URL renders those fine, but our MJPEG
  399. ``multipart/x-mixed-replace`` stream hard-labels every part
  400. ``Content-Type: image/jpeg`` — so a non-JPEG payload makes the browser reject
  401. the frame and drop the whole stream ("connection lost", #1902). Transcoding to
  402. JPEG keeps the stream genuinely MJPEG and also keeps the JPEG-only downstream
  403. (plate detection, Obico, finish photo) working.
  404. Returns None if the bytes are not a decodable image (e.g. an HTML error page)
  405. or if the imaging libraries are unavailable — callers fall back to the raw
  406. bytes so behaviour is never worse than before.
  407. """
  408. try:
  409. import cv2
  410. import numpy as np
  411. except ImportError:
  412. return None
  413. try:
  414. img = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_COLOR)
  415. if img is None:
  416. return None
  417. ok, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 85])
  418. if not ok:
  419. return None
  420. return buf.tobytes()
  421. except Exception as e: # cv2 raises cv2.error (a subclass of Exception) on bad input
  422. logger.debug("Snapshot transcode to JPEG failed: %s", e)
  423. return None
  424. async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
  425. """Fetch snapshot from HTTP URL.
  426. Note: This function intentionally makes requests to user-configured URLs.
  427. External camera support requires connecting to user-specified camera endpoints.
  428. URL is sanitized and dangerous destinations are blocked.
  429. """
  430. # Sanitize URL - returns reconstructed URL from validated components
  431. safe_url = _sanitize_camera_url(url, ("http", "https"))
  432. if not safe_url:
  433. logger.error("Invalid snapshot URL format: %s...", redact_url_credentials(url)[:50])
  434. return None
  435. try:
  436. async with (
  437. aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session,
  438. session.get(safe_url) as response,
  439. ):
  440. if response.status != 200:
  441. logger.error("Snapshot URL returned status %s", response.status)
  442. return None
  443. data = await response.read()
  444. except TimeoutError:
  445. logger.warning("Snapshot capture timed out after %ss", timeout)
  446. return None
  447. except (aiohttp.ClientError, OSError) as e:
  448. logger.error("Snapshot capture failed: %s", e)
  449. return None
  450. # Fast path: already JPEG (SOI marker), stream it as-is (no decode/re-encode).
  451. if data.startswith(b"\xff\xd8"):
  452. return data
  453. # Not JPEG. Many snapshot endpoints serve PNG/WebP/BMP — transcode to JPEG so
  454. # the browser's MJPEG stream (and JPEG-only downstream) keep working instead of
  455. # dropping the connection (#1902). Run off the event loop: cv2 decode/encode is
  456. # CPU-bound and this can be polled at up to 15 fps while a camera view is open.
  457. transcoded = await asyncio.to_thread(_transcode_to_jpeg, data)
  458. if transcoded is not None:
  459. logger.debug(
  460. "Transcoded non-JPEG snapshot (%d bytes, header %s) to JPEG",
  461. len(data),
  462. data[:4].hex(),
  463. )
  464. return transcoded
  465. # Couldn't decode it as an image at all — most likely not an image response
  466. # (HTML error page, auth redirect, wrong URL). Return the raw bytes as a last
  467. # resort (unchanged behaviour) but log enough to debug.
  468. logger.warning(
  469. "External camera snapshot is not a decodable image "
  470. "(%d bytes, header %s) — verify the camera URL returns an image",
  471. len(data),
  472. data[:4].hex(),
  473. )
  474. return data
  475. async def test_connection(url: str, camera_type: str) -> dict:
  476. """Test camera connection.
  477. Returns:
  478. Dict with {success: bool, error?: str, resolution?: str}
  479. """
  480. logger.info("Testing camera connection: type=%s, url=%s...", camera_type, redact_url_credentials(url)[:50])
  481. try:
  482. frame = await capture_frame(url, camera_type, timeout=10)
  483. logger.info("Capture result: %s bytes", len(frame) if frame else 0)
  484. if frame:
  485. # Try to get resolution from JPEG header
  486. resolution = None
  487. try:
  488. # Simple JPEG dimension extraction
  489. # SOF0 marker is FF C0, followed by length, precision, height, width
  490. sof_markers = [b"\xff\xc0", b"\xff\xc1", b"\xff\xc2"]
  491. for marker in sof_markers:
  492. idx = frame.find(marker)
  493. if idx != -1 and idx + 9 <= len(frame):
  494. height = (frame[idx + 5] << 8) | frame[idx + 6]
  495. width = (frame[idx + 7] << 8) | frame[idx + 8]
  496. resolution = f"{width}x{height}"
  497. break
  498. except (IndexError, ValueError):
  499. pass # Resolution detection is optional; fall back to default
  500. return {"success": True, "resolution": resolution}
  501. else:
  502. return {"success": False, "error": "Failed to capture frame from camera"}
  503. except Exception as e:
  504. # Sanitize error message - don't expose internal details
  505. error_type = type(e).__name__
  506. logger.error("Camera connection test failed: %s", e)
  507. return {"success": False, "error": f"Connection failed: {error_type}"}
  508. async def generate_mjpeg_stream(
  509. url: str,
  510. camera_type: str,
  511. fps: int = 10,
  512. *,
  513. on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
  514. on_frame: Callable[[bytes], None] | None = None,
  515. stop_event: asyncio.Event | None = None,
  516. ) -> AsyncGenerator[bytes, None]:
  517. """Generator yielding MJPEG frames for streaming.
  518. Args:
  519. url: Camera URL or USB device path
  520. camera_type: "mjpeg", "rtsp", "snapshot", or "usb"
  521. fps: Target frames per second
  522. on_process: Called with the spawned ffmpeg process for the ``usb`` and
  523. ``rtsp`` paths so the route layer can register it into the shared
  524. stream registries — that's what lets ``/camera/stop`` and the orphan
  525. janitor find and kill a leaked ffmpeg that's holding a USB device
  526. open (#2675). Without it the process is reachable only from this
  527. generator's own ``finally``, which an abrupt client disconnect can
  528. skip (same cancellation-timing class as #776).
  529. on_frame: Called with each RAW frame, before it is wrapped for the wire,
  530. so the route layer can publish it as the printer's buffered frame
  531. (#2707). It has to be a callback: what this generator yields is
  532. multipart-wrapped, so a consumer of the stream cannot recover the
  533. JPEG, and until now nothing populated the buffer for external
  534. cameras at all — leaving every one-shot consumer (layer timelapse,
  535. finish photo, Obico, plate check) with nothing to reuse and no
  536. option but to open a competing handle on a single-reader device.
  537. Exceptions are logged and swallowed: buffering must never be able
  538. to break the live stream.
  539. stop_event: When set, the reconnect loops stop retrying — so an explicit
  540. stop (which kills the current ffmpeg) doesn't immediately respawn a
  541. new process and reacquire the device.
  542. Yields:
  543. MJPEG frame data with HTTP multipart boundaries
  544. """
  545. frame_interval = 1.0 / max(fps, 1)
  546. last_frame_time = 0.0
  547. def _publish(frame: bytes) -> bytes:
  548. """Hand the raw frame to on_frame, then format it for the wire."""
  549. if on_frame is not None:
  550. try:
  551. on_frame(frame)
  552. except Exception:
  553. logger.exception("on_frame callback raised")
  554. return _format_mjpeg_frame(frame)
  555. if camera_type == "mjpeg":
  556. # Proxy MJPEG stream directly, with reconnect on timeout
  557. max_retries = 3
  558. for attempt in range(max_retries + 1):
  559. frame_yielded = False
  560. async for frame in _stream_mjpeg(url):
  561. frame_yielded = True
  562. current_time = asyncio.get_event_loop().time()
  563. if current_time - last_frame_time >= frame_interval:
  564. last_frame_time = current_time
  565. yield _publish(frame)
  566. if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
  567. break
  568. logger.warning(
  569. "External MJPEG stream ended, reconnecting (attempt %d/%d)...",
  570. attempt + 1,
  571. max_retries,
  572. )
  573. await asyncio.sleep(2)
  574. elif camera_type == "rtsp":
  575. # Use ffmpeg to convert RTSP to MJPEG, with reconnect on timeout
  576. max_retries = 3
  577. for attempt in range(max_retries + 1):
  578. frame_yielded = False
  579. async for frame in _stream_rtsp(url, fps, on_process=on_process):
  580. frame_yielded = True
  581. yield _publish(frame)
  582. if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
  583. break
  584. logger.warning(
  585. "External RTSP stream ended, reconnecting (attempt %d/%d)...",
  586. attempt + 1,
  587. max_retries,
  588. )
  589. await asyncio.sleep(2)
  590. elif camera_type == "usb":
  591. # Use ffmpeg to stream from USB camera
  592. async for frame in _stream_usb(url, fps, on_process=on_process):
  593. yield _publish(frame)
  594. elif camera_type == "snapshot":
  595. # Poll snapshot URL at interval
  596. while True:
  597. try:
  598. frame = await _capture_snapshot(url, timeout=10)
  599. if frame:
  600. yield _publish(frame)
  601. await asyncio.sleep(frame_interval)
  602. except asyncio.CancelledError:
  603. break
  604. except (aiohttp.ClientError, OSError) as e:
  605. logger.warning("Snapshot poll failed: %s", e)
  606. await asyncio.sleep(frame_interval)
  607. def _format_mjpeg_frame(frame: bytes) -> bytes:
  608. """Format frame for MJPEG HTTP response."""
  609. return (
  610. b"--frame\r\n"
  611. b"Content-Type: image/jpeg\r\n"
  612. b"Content-Length: " + str(len(frame)).encode() + b"\r\n"
  613. b"\r\n" + frame + b"\r\n"
  614. )
  615. async def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]:
  616. """Stream frames from MJPEG URL.
  617. Note: This function intentionally makes requests to user-configured URLs.
  618. External camera support requires connecting to user-specified camera endpoints.
  619. URL is sanitized and dangerous destinations are blocked.
  620. """
  621. # Sanitize URL - returns reconstructed URL from validated components
  622. safe_url = _sanitize_camera_url(url, ("http", "https"))
  623. if not safe_url:
  624. logger.error("Invalid MJPEG stream URL: %s...", redact_url_credentials(url)[:50])
  625. return
  626. try:
  627. timeout = aiohttp.ClientTimeout(total=None, sock_read=30)
  628. async with aiohttp.ClientSession(timeout=timeout) as session, session.get(safe_url) as response:
  629. if response.status != 200:
  630. logger.error("MJPEG stream returned status %s", response.status)
  631. return
  632. buffer = b""
  633. jpeg_start = b"\xff\xd8"
  634. jpeg_end = b"\xff\xd9"
  635. async for chunk in response.content.iter_chunked(8192):
  636. buffer += chunk
  637. # Extract complete frames from buffer
  638. while True:
  639. start_idx = buffer.find(jpeg_start)
  640. if start_idx == -1:
  641. buffer = buffer[-2:] if len(buffer) > 2 else buffer
  642. break
  643. if start_idx > 0:
  644. buffer = buffer[start_idx:]
  645. end_idx = buffer.find(jpeg_end, 2)
  646. if end_idx == -1:
  647. break
  648. frame = buffer[: end_idx + 2]
  649. buffer = buffer[end_idx + 2 :]
  650. yield frame
  651. except asyncio.CancelledError:
  652. logger.info("MJPEG stream cancelled")
  653. except (aiohttp.ClientError, OSError) as e:
  654. logger.error("MJPEG stream error: %s", e)
  655. async def _stream_rtsp(
  656. url: str,
  657. fps: int,
  658. *,
  659. on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
  660. ) -> AsyncGenerator[bytes, None]:
  661. """Stream frames from RTSP URL via ffmpeg.
  662. For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead
  663. of relying on ffmpeg's GnuTLS backend, which has compatibility issues
  664. with some printer firmwares.
  665. """
  666. ffmpeg = get_ffmpeg_path()
  667. if not ffmpeg:
  668. logger.error("ffmpeg not found - required for RTSP streaming")
  669. return
  670. from backend.app.services.camera import rtsp_socket_timeout_flag
  671. # If the URL uses rtsps://, set up a TLS proxy so ffmpeg uses plain rtsp://
  672. proxy_server = None
  673. effective_url = url
  674. if url.lower().startswith("rtsps://"):
  675. try:
  676. from urllib.parse import urlparse
  677. from backend.app.services.camera import create_tls_proxy
  678. parsed = urlparse(url)
  679. target_port = parsed.port or 322
  680. proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
  681. # Rewrite URL: rtsps://user:pass@host:port/path → rtsp://user:pass@127.0.0.1:proxy/path
  682. userinfo = ""
  683. if parsed.username:
  684. userinfo = parsed.username
  685. if parsed.password:
  686. userinfo += f":{parsed.password}"
  687. userinfo += "@"
  688. effective_url = f"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}"
  689. if parsed.query:
  690. effective_url += f"?{parsed.query}"
  691. except Exception as e:
  692. logger.warning("Failed to create TLS proxy for RTSP, falling back to direct: %s", e)
  693. effective_url = url
  694. cmd = [
  695. ffmpeg,
  696. "-rtsp_transport",
  697. "tcp",
  698. "-rtsp_flags",
  699. "prefer_tcp",
  700. # Socket I/O timeout name varies by ffmpeg version (#1504); see
  701. # `rtsp_socket_timeout_flag()` in services.camera.
  702. f"-{rtsp_socket_timeout_flag()}",
  703. "30000000",
  704. "-buffer_size",
  705. "1024000",
  706. "-max_delay",
  707. "500000",
  708. "-probesize",
  709. "32",
  710. "-analyzeduration",
  711. "0",
  712. "-fflags",
  713. "nobuffer",
  714. "-flags",
  715. "low_delay",
  716. "-i",
  717. effective_url,
  718. "-f",
  719. "mjpeg",
  720. "-q:v",
  721. "5",
  722. "-r",
  723. str(fps),
  724. "-an",
  725. "-",
  726. ]
  727. process = None
  728. try:
  729. process = await asyncio.create_subprocess_exec(
  730. *cmd,
  731. stdout=asyncio.subprocess.PIPE,
  732. stderr=asyncio.subprocess.PIPE,
  733. )
  734. # Register immediately — before the startup probe below — so a process
  735. # that hangs on connect (rather than exiting) is still reachable by the
  736. # stop endpoint / orphan janitor (#2675).
  737. if on_process is not None:
  738. on_process(process)
  739. # Brief check for immediate startup failures
  740. await asyncio.sleep(0.1)
  741. if process.returncode is not None:
  742. stderr = await process.stderr.read()
  743. # ffmpeg echoes the RTSP input URL, which carries the camera password.
  744. logger.error("ffmpeg RTSP stream failed immediately: %s", redact_url_credentials(stderr.decode())[:300])
  745. return
  746. buffer = b""
  747. jpeg_start = b"\xff\xd8"
  748. jpeg_end = b"\xff\xd9"
  749. while True:
  750. try:
  751. chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=30.0)
  752. if not chunk:
  753. break
  754. buffer += chunk
  755. # Extract complete frames
  756. while True:
  757. start_idx = buffer.find(jpeg_start)
  758. if start_idx == -1:
  759. buffer = buffer[-2:] if len(buffer) > 2 else buffer
  760. break
  761. if start_idx > 0:
  762. buffer = buffer[start_idx:]
  763. end_idx = buffer.find(jpeg_end, 2)
  764. if end_idx == -1:
  765. break
  766. frame = buffer[: end_idx + 2]
  767. buffer = buffer[end_idx + 2 :]
  768. yield frame
  769. except TimeoutError:
  770. logger.warning("RTSP stream read timeout")
  771. break
  772. except asyncio.CancelledError:
  773. logger.info("RTSP stream cancelled")
  774. except OSError as e:
  775. logger.error("RTSP stream error: %s", e)
  776. finally:
  777. if process and process.returncode is None:
  778. process.terminate()
  779. try:
  780. await asyncio.wait_for(process.wait(), timeout=2.0)
  781. except TimeoutError:
  782. process.kill()
  783. await process.wait()
  784. if proxy_server:
  785. proxy_server.close()
  786. await proxy_server.wait_closed()
  787. async def _stream_usb(
  788. device: str,
  789. fps: int,
  790. *,
  791. on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
  792. ) -> AsyncGenerator[bytes, None]:
  793. """Stream frames from USB camera via ffmpeg."""
  794. ffmpeg = get_ffmpeg_path()
  795. if not ffmpeg:
  796. logger.error("ffmpeg not found - required for USB camera streaming")
  797. return
  798. # Validate device path
  799. if not device.startswith("/dev/video"):
  800. logger.error("Invalid USB device path: %s", device)
  801. return
  802. if not Path(device).exists():
  803. logger.error("USB device does not exist: %s", device)
  804. return
  805. # ffmpeg command to stream from USB camera (v4l2)
  806. cmd = [
  807. ffmpeg,
  808. "-f",
  809. "v4l2",
  810. "-framerate",
  811. str(fps),
  812. "-i",
  813. device,
  814. "-f",
  815. "mjpeg",
  816. "-q:v",
  817. "5",
  818. "-r",
  819. str(fps),
  820. "-",
  821. ]
  822. process = None
  823. try:
  824. logger.info("Starting USB camera stream from %s at %s fps", device, fps)
  825. process = await asyncio.create_subprocess_exec(
  826. *cmd,
  827. stdout=asyncio.subprocess.PIPE,
  828. stderr=asyncio.subprocess.PIPE,
  829. )
  830. # Register immediately — before the startup probe below — so a process
  831. # that hangs in open()/ioctl on a still-locked device (rather than
  832. # exiting with a "busy" error) is still reachable by the stop endpoint /
  833. # orphan janitor (#2675).
  834. if on_process is not None:
  835. on_process(process)
  836. # Give ffmpeg a moment to start and check for immediate failures
  837. await asyncio.sleep(0.5)
  838. if process.returncode is not None:
  839. stderr = await process.stderr.read()
  840. logger.error("ffmpeg USB stream failed immediately: %s", stderr.decode()[:300])
  841. return
  842. buffer = b""
  843. jpeg_start = b"\xff\xd8"
  844. jpeg_end = b"\xff\xd9"
  845. while True:
  846. try:
  847. chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=30.0)
  848. if not chunk:
  849. break
  850. buffer += chunk
  851. # Extract complete frames
  852. while True:
  853. start_idx = buffer.find(jpeg_start)
  854. if start_idx == -1:
  855. buffer = buffer[-2:] if len(buffer) > 2 else buffer
  856. break
  857. if start_idx > 0:
  858. buffer = buffer[start_idx:]
  859. end_idx = buffer.find(jpeg_end, 2)
  860. if end_idx == -1:
  861. break
  862. frame = buffer[: end_idx + 2]
  863. buffer = buffer[end_idx + 2 :]
  864. yield frame
  865. except TimeoutError:
  866. logger.warning("USB stream read timeout")
  867. break
  868. except asyncio.CancelledError:
  869. logger.info("USB stream cancelled")
  870. except OSError as e:
  871. logger.error("USB stream error: %s", e)
  872. finally:
  873. if process and process.returncode is None:
  874. process.terminate()
  875. try:
  876. await asyncio.wait_for(process.wait(), timeout=2.0)
  877. except TimeoutError:
  878. process.kill()
  879. await process.wait()