external_camera.py 47 KB

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