external_camera.py 62 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525
  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 contextlib
  9. import functools
  10. import ipaddress
  11. import logging
  12. import re
  13. import shutil
  14. import socket
  15. import time
  16. import zlib
  17. from collections.abc import AsyncGenerator, Callable
  18. from pathlib import Path
  19. from urllib.parse import urljoin, urlparse
  20. import aiohttp
  21. from backend.app.core.logging_filters import redact_url_credentials
  22. from backend.app.utils.ffmpeg_output import (
  23. FFMPEG_RTSP_LOGLEVEL,
  24. NO_FFMPEG_OUTPUT,
  25. read_ffmpeg_log_until_open,
  26. reap_ffmpeg,
  27. summarize_ffmpeg_stderr,
  28. watch_ffmpeg_log,
  29. )
  30. logger = logging.getLogger(__name__)
  31. # Protocols ffmpeg may use for an RTSP input. RTSP negotiates its media
  32. # transport at runtime, so the transports have to be here alongside rtsp itself;
  33. # tls and crypto cover encrypted variants. Everything ffmpeg would otherwise
  34. # accept behind an -i — file, http, tcp to anywhere, concat — is left out, so a
  35. # stream that references something outside itself cannot pull it in.
  36. _RTSP_PROTOCOL_WHITELIST = "rtsp,rtp,udp,tcp,tls,crypto"
  37. def _blocked_host_reason(hostname: str) -> str | None:
  38. """Describe why *hostname* is a destination we refuse to fetch, or None to allow it.
  39. Camera URLs are user-supplied and reach the network — over aiohttp for the
  40. HTTP types, and as an ``ffmpeg -i`` argument for RTSP — so this is where the
  41. SSRF boundary sits. LAN addresses are deliberately allowed: cameras live on
  42. the same network as Bambuddy, and blocking RFC-1918 would remove the feature
  43. rather than protect it. What is left to refuse is the host talking to
  44. itself, the unspecified address, link-local (which is where the cloud
  45. metadata endpoint lives), and the metadata hostnames.
  46. IP literals are classified with ``ipaddress`` rather than compared against a
  47. list of spellings, because 127.0.0.1, 127.0.0.2, 2130706433, 0177.0.0.1,
  48. 127.1 and ::ffff:127.0.0.1 all arrive at loopback and a list of strings only
  49. ever catches whichever one someone thought to write down. ``inet_aton``
  50. comes first because it accepts the legacy octal, decimal and short forms
  51. that ``ip_address`` rejects — the C resolvers behind aiohttp and ffmpeg
  52. accept them, so refusing to understand them here would only mean not seeing
  53. where the request is actually going.
  54. """
  55. from backend.app.api.routes._url_safety import CLOUD_METADATA_HOSTNAMES, CLOUD_METADATA_IPS, unwrap_ipv4_mapped
  56. # A trailing dot names the same host to every resolver.
  57. host = hostname.lower().rstrip(".")
  58. # The HTTP clients map other dot characters, fullwidth digits and similar
  59. # forms to plain ASCII before they connect: a host not written in ASCII
  60. # cannot be judged here, so it is refused.
  61. if not host.isascii():
  62. return "a host name not written in ASCII (use punycode, xn--...)"
  63. ip: ipaddress.IPv4Address | ipaddress.IPv6Address | None = None
  64. try:
  65. ip = ipaddress.ip_address(socket.inet_aton(host))
  66. except OSError:
  67. try:
  68. ip = ipaddress.ip_address(host)
  69. except ValueError:
  70. ip = None
  71. if ip is None:
  72. # A name, not an address. It is not resolved here on purpose: aiohttp
  73. # and ffmpeg each resolve independently afterwards, so a check here
  74. # decides nothing about where they end up (DNS rebinding), while a
  75. # lookup on every capture would break LAN cameras behind slow or
  76. # intermittent local DNS.
  77. if host == "localhost" or host.endswith(".localhost"):
  78. return "localhost"
  79. if host in CLOUD_METADATA_HOSTNAMES:
  80. return "a cloud metadata service"
  81. return None
  82. # ::ffff:127.0.0.1 is loopback wearing an IPv6 spelling, and a zone id
  83. # only hides the address it is attached to.
  84. ip = unwrap_ipv4_mapped(ip)
  85. if ip in CLOUD_METADATA_IPS:
  86. return "a cloud metadata service"
  87. if ip.is_loopback:
  88. return "loopback"
  89. if ip.is_unspecified:
  90. return "the unspecified address"
  91. if ip.is_link_local:
  92. return "a link-local address (the cloud metadata range)"
  93. return None
  94. def _sanitize_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "https", "rtsp")) -> str | None:
  95. """Validate and sanitize camera URL, returning a safe reconstructed URL.
  96. This validates that the URL is well-formed, uses an allowed scheme, does not
  97. target the host itself or a cloud metadata service, and returns a URL
  98. reconstructed from the validated components.
  99. Note: This intentionally allows user-provided URLs as that is the
  100. purpose of external camera configuration. Local network IPs are
  101. allowed since cameras are typically on the same LAN.
  102. Args:
  103. url: URL to validate and sanitize
  104. allowed_schemes: Tuple of allowed URL schemes
  105. Returns:
  106. Sanitized URL string if valid, None otherwise
  107. """
  108. try:
  109. parsed = urlparse(url)
  110. if not parsed.scheme or not parsed.netloc:
  111. return None
  112. # Validate scheme against allowlist
  113. scheme = parsed.scheme.lower()
  114. if scheme not in allowed_schemes:
  115. return None
  116. hostname = parsed.hostname or ""
  117. if not hostname:
  118. return None
  119. blocked = _blocked_host_reason(hostname)
  120. if blocked:
  121. logger.warning("Blocked camera URL targeting %s: %s", blocked, hostname)
  122. return None
  123. # Reconstruct URL from validated components to break taint chain
  124. # This creates a new string from validated parts
  125. #
  126. # The credentials are carried across verbatim from netloc rather than
  127. # via parsed.username/.password, which urlparse has already percent-
  128. # decoded: re-emitting those would corrupt any password containing an
  129. # @ or a :. They have to survive at all because most RTSP cameras — and
  130. # a fair number of MJPEG ones — carry their login in the URL, and
  131. # dropping it turns every one of them into an authentication failure.
  132. netloc = parsed.netloc
  133. userinfo = f"{netloc.rsplit('@', 1)[0]}@" if "@" in netloc else ""
  134. # parsed.hostname has already stripped the brackets off an IPv6 literal;
  135. # without them back the result is not a URL any client can parse.
  136. host_str = f"[{hostname}]" if ":" in hostname else hostname
  137. port_str = f":{parsed.port}" if parsed.port else ""
  138. path = parsed.path or ""
  139. query = f"?{parsed.query}" if parsed.query else ""
  140. fragment = f"#{parsed.fragment}" if parsed.fragment else ""
  141. # Build sanitized URL from validated components
  142. sanitized = f"{scheme}://{userinfo}{host_str}{port_str}{path}{query}{fragment}"
  143. return sanitized
  144. except ValueError:
  145. return None
  146. # The device names a USB camera can stream from: _safe_usb_device_path
  147. # accepts /dev/video0 to /dev/video99, written without a leading zero.
  148. _USB_DEVICE_NAME = re.compile(r"^video([0-9]|[1-9][0-9])$")
  149. def list_usb_cameras(dev_dir: Path = Path("/dev")) -> list[dict]:
  150. """List available USB cameras (V4L2 devices on Linux).
  151. Blocking: runs v4l2-ctl for each device, so call it from a thread.
  152. Returns:
  153. List of dicts with {device: str, name: str, capabilities: list}
  154. """
  155. cameras = []
  156. video_devices = sorted(
  157. (device for device in dev_dir.glob("video*") if _USB_DEVICE_NAME.match(device.name)),
  158. key=lambda device: int(device.name.removeprefix("video")),
  159. )
  160. for device in video_devices:
  161. device_path = f"/dev/{device.name}"
  162. info = {"device": device_path, "name": device.name, "capabilities": []}
  163. # Try to get device info via v4l2-ctl
  164. v4l2_ctl = shutil.which("v4l2-ctl")
  165. if v4l2_ctl:
  166. import subprocess
  167. try:
  168. result = subprocess.run(
  169. [v4l2_ctl, "-d", device_path, "--info"],
  170. capture_output=True,
  171. text=True,
  172. timeout=5,
  173. )
  174. if result.returncode == 0:
  175. # Parse device name from output
  176. for line in result.stdout.splitlines():
  177. if "Card type" in line:
  178. info["name"] = line.split(":", 1)[1].strip()
  179. elif "Driver name" in line:
  180. info["driver"] = line.split(":", 1)[1].strip()
  181. # Check if device supports video capture
  182. result = subprocess.run(
  183. [v4l2_ctl, "-d", device_path, "--list-formats"],
  184. capture_output=True,
  185. text=True,
  186. timeout=5,
  187. )
  188. if result.returncode == 0 and result.stdout.strip():
  189. info["capabilities"].append("capture")
  190. # Parse available formats
  191. formats = re.findall(r"'(\w+)'", result.stdout)
  192. info["formats"] = list(set(formats))
  193. except (subprocess.TimeoutExpired, Exception) as e:
  194. logger.debug("v4l2-ctl failed for %s: %s", device_path, e)
  195. # Only include devices that look like video capture devices
  196. # Skip metadata devices (typically odd numbered like video1, video3)
  197. device_num = int(device.name.removeprefix("video"))
  198. # Even numbered devices are usually capture, odd are metadata
  199. # But also check if we got capabilities
  200. if info.get("capabilities") or device_num % 2 == 0:
  201. cameras.append(info)
  202. return cameras
  203. def get_ffmpeg_path() -> str | None:
  204. """Get the path to ffmpeg executable."""
  205. # Try shutil.which first
  206. path = shutil.which("ffmpeg")
  207. if path:
  208. return path
  209. # Check common locations (systemd services may have limited PATH)
  210. for common_path in ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"]:
  211. if Path(common_path).exists():
  212. return common_path
  213. return None
  214. # In-flight one-shot captures, keyed by (url, camera_type, snapshot_url) —
  215. # the tuple that actually identifies the physical resource being contended
  216. # (#2707 comment thread, following #2705's shape for the built-in path).
  217. #
  218. # V4L2 USB devices allow exactly one open handle, and is_stream_active() /
  219. # try_get_active_buffered_frame() (#2707) only stop a one-shot capturer from
  220. # competing with the fan-out live view. They do nothing for capturer-vs-
  221. # capturer with no viewer attached, where every consumer correctly concludes
  222. # it isn't competing with a viewer and then collides with the others -
  223. # exactly the #2705 report, just for this module's callers instead of
  224. # capture_camera_frame_bytes()'s (Obico polling, the in-print frame bank,
  225. # the finish-photo moment, plate detection, and the notification snapshot
  226. # all reach capture_frame() independently).
  227. #
  228. # snapshot_url is part of the key (not just url/camera_type) because it
  229. # routes to a completely different endpoint (#1177) - two printers that
  230. # share a camera_url but differ only in snapshot_url must not coalesce.
  231. _inflight_captures: dict[tuple[str, str, str | None], asyncio.Task[bytes | None]] = {}
  232. def capture_in_flight(url: str, camera_type: str, snapshot_url: str | None = None) -> bool:
  233. """Return True iff a one-shot capture for this key is running right now.
  234. Mirrors camera.py's capture_in_flight() for the built-in path - for a
  235. caller that needs to know it will JOIN someone else's capture rather
  236. than open its own connection. Ordinary consumers should ignore this:
  237. they want "a recent frame", and capture_frame() already does the right
  238. thing for them.
  239. """
  240. task = _inflight_captures.get((url, camera_type, snapshot_url))
  241. return task is not None and not task.done()
  242. def _discard_inflight_capture(key: tuple[str, str, str | None], task: asyncio.Task) -> None:
  243. """Done-callback: drop the finished task from the in-flight registry.
  244. Guarded on identity so a slow task that finishes after a newer capture
  245. has registered for the same key can't evict its successor.
  246. Also retrieves the exception, if any: the leader normally awaits the
  247. task and would surface it, but a leader whose own caller was cancelled
  248. leaves nobody to collect it, and an unretrieved task exception is
  249. logged by asyncio as a warning with a traceback at an arbitrary later
  250. point otherwise.
  251. """
  252. if _inflight_captures.get(key) is task:
  253. del _inflight_captures[key]
  254. if not task.cancelled() and task.exception() is not None:
  255. logger.debug("In-flight external-camera capture for %s ended in an exception", _log_key(key))
  256. def _log_key(key: tuple[str, str, str | None]) -> str:
  257. """Render an in-flight key for a log line, with credentials redacted.
  258. Unlike camera.py's coalescing — which is keyed by IP address and so has
  259. nothing to hide — these keys carry the camera URL, and an RTSP camera URL
  260. routinely embeds ``user:pass@``. Redact before truncating: slicing first
  261. can cut the URL short of the ``@`` the pattern anchors on and leave the
  262. password in the log, which is why every other URL log in this module does
  263. it in this order.
  264. """
  265. return redact_url_credentials(key[0])[:50] if key[0] else "None"
  266. async def capture_frame(
  267. url: str,
  268. camera_type: str,
  269. timeout: int = 15,
  270. snapshot_url: str | None = None,
  271. ) -> bytes | None:
  272. """Capture single frame from external camera.
  273. Args:
  274. url: Live-stream URL (MJPEG stream, RTSP URL, HTTP snapshot URL, or USB device path).
  275. camera_type: "mjpeg", "rtsp", "snapshot", or "usb".
  276. timeout: Connection timeout in seconds. Applies to this caller's own
  277. wait, including when it joins another caller's capture - call
  278. sites disagree about the value, and a follower must not silently
  279. inherit the leader's deadline in either direction.
  280. snapshot_url: Optional override for single-frame capture. When set, fetched
  281. via plain HTTP GET regardless of `camera_type`. Bypasses MJPEG warm-up
  282. handling on sources that expose a dedicated frame endpoint (e.g. go2rtc's
  283. `/api/frame.jpeg` reliably returns a clean image while the MJPEG stream's
  284. first frame is often the encoder's stale keyframe). #1177.
  285. Returns:
  286. JPEG bytes or None on failure
  287. Concurrent callers for the same (url, camera_type, snapshot_url) share
  288. one capture (#2705-shape fix, filed for the external-camera path as a
  289. follow-up on #2707): the first opens the connection, everyone arriving
  290. while it's in flight awaits the same result. This coalesces; it does
  291. not cache - a call that arrives after the previous capture finished
  292. always captures fresh, since plate detection and the finish-photo path
  293. judge a running print from these frames and a stale one there is worse
  294. than a slow one (#1397).
  295. """
  296. key = (url, camera_type, snapshot_url)
  297. # A follower whose leader fails takes a turn of its own rather than
  298. # inheriting a failure it never had a chance to avoid - by then the
  299. # leader has finished, so there's no connection left to compete with.
  300. # Bounded at two rounds: if the capture we joined AND its replacement
  301. # both failed, a third attempt won't help, and this caller has already
  302. # spent its patience.
  303. for _ in range(2):
  304. leader = _inflight_captures.get(key)
  305. if leader is None or leader.done():
  306. break
  307. try:
  308. frame = await asyncio.wait_for(asyncio.shield(leader), timeout=timeout)
  309. except TimeoutError:
  310. # shield() keeps the capture running for whoever else is still
  311. # waiting on it - giving up is this caller's decision alone.
  312. logger.warning(
  313. "Gave up waiting %ss on the in-flight external-camera capture for %s", timeout, _log_key(key)
  314. )
  315. return None
  316. except asyncio.CancelledError:
  317. # Distinguish "the capture I joined was cancelled" from "I was
  318. # cancelled". Only the former is ours to recover from.
  319. if not leader.cancelled():
  320. raise
  321. logger.info("In-flight external-camera capture for %s was cancelled; capturing our own", _log_key(key))
  322. continue
  323. if frame is not None:
  324. logger.debug(
  325. "Reusing in-flight external-camera capture for %s: %d bytes (no second connection opened)",
  326. _log_key(key),
  327. len(frame),
  328. )
  329. return frame
  330. logger.debug("In-flight external-camera capture for %s failed; capturing our own", _log_key(key))
  331. else:
  332. return None
  333. task = asyncio.create_task(_capture_frame_uncoalesced(url, camera_type, timeout, snapshot_url))
  334. _inflight_captures[key] = task
  335. task.add_done_callback(functools.partial(_discard_inflight_capture, key))
  336. # No wait_for here: this caller IS the capture, and each dispatched
  337. # _capture_* function already enforces `timeout` internally, where it
  338. # can also kill the ffmpeg process - a second deadline on top would
  339. # abandon the subprocess instead of killing it. shield() so a cancelled
  340. # leader (a client navigating away mid-request is routine) doesn't take
  341. # the capture down with it - followers already waiting on it still get
  342. # their frame.
  343. return await asyncio.shield(task)
  344. async def _capture_frame_uncoalesced(
  345. url: str,
  346. camera_type: str,
  347. timeout: int,
  348. snapshot_url: str | None,
  349. ) -> bytes | None:
  350. """Open a connection and capture one frame. See capture_frame().
  351. Callers want that wrapper, not this: it opens a connection
  352. unconditionally, which is the collision #2705/#2707 are about.
  353. Failure is reported as ``None``, never as an exception. That is load-
  354. bearing now that captures are shared: the coalescing wrapper hands one
  355. task's outcome to every caller waiting on it, and it can only give a
  356. follower its own turn for an outcome it can recognise. An exception
  357. escaping here would instead propagate to every follower at once —
  358. turning one caller's failure into N — and none of them would retry.
  359. The per-type helpers below each catch what they expect and return None,
  360. but they catch narrowly (``aiohttp.ClientError``/``OSError``/timeouts),
  361. so this is the structural guarantee rather than one contingent on their
  362. coverage. Mirrors ``_capture_camera_frame_bytes_uncoalesced`` in
  363. camera.py, which ends in the same blanket catch for the same reason.
  364. """
  365. try:
  366. if snapshot_url:
  367. # Redact before truncating — slicing first can cut the URL short of the
  368. # ``@`` the pattern anchors on and leave the password in the log.
  369. logger.debug("capture_frame using snapshot override url=%s...", redact_url_credentials(snapshot_url)[:50])
  370. return await _capture_snapshot(snapshot_url, timeout)
  371. logger.debug(
  372. "capture_frame called: type=%s, url=%s...",
  373. camera_type,
  374. redact_url_credentials(url)[:50] if url else "None",
  375. )
  376. if camera_type == "mjpeg":
  377. return await _capture_mjpeg_frame(url, timeout)
  378. elif camera_type == "rtsp":
  379. return await _capture_rtsp_frame(url, timeout)
  380. elif camera_type == "snapshot":
  381. return await _capture_snapshot(url, timeout)
  382. elif camera_type == "usb":
  383. return await _capture_usb_frame(url, timeout)
  384. else:
  385. logger.warning("Unknown camera type: %s", camera_type)
  386. return None
  387. except asyncio.CancelledError:
  388. # Cancellation is not a capture failure and must stay distinguishable:
  389. # the wrapper checks ``leader.cancelled()`` to decide whether a
  390. # follower may take its own turn.
  391. raise
  392. except Exception:
  393. logger.exception("External camera capture failed for %s", redact_url_credentials(url)[:50] if url else "None")
  394. return None
  395. def _safe_usb_device_path(device: str) -> str | None:
  396. """Rebuild a /dev/videoN path from a validated device number, or None.
  397. Validate device path - must be /dev/videoN format where N is 0-99. This
  398. prevents path traversal by using a strict allowlist approach: the returned
  399. path is built from an integer, which cannot carry a traversal, rather than
  400. from any part of the caller's string.
  401. Returns None if the device does not exist, so a caller cannot hand ffmpeg a
  402. path to something that is not a device node.
  403. """
  404. device_match = re.match(r"^/dev/video(\d{1,2})$", device)
  405. if not device_match:
  406. logger.error("Invalid USB device path format: %s", device)
  407. return None
  408. # Convert to integer to break taint chain - integers cannot contain path traversal
  409. # lgtm[py/path-injection] - device_num is validated integer 0-99
  410. device_num = int(device_match.group(1)) # Safe: regex guarantees 1-2 digits
  411. # Construct safe path from validated integer (completely untainted)
  412. safe_device_path = Path(f"/dev/video{device_num}") # lgtm[py/path-injection]
  413. if not safe_device_path.exists():
  414. logger.error("USB device does not exist: %s", safe_device_path)
  415. return None
  416. return str(safe_device_path) # lgtm[py/path-injection]
  417. async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
  418. """Capture frame from USB camera using ffmpeg."""
  419. ffmpeg = get_ffmpeg_path()
  420. if not ffmpeg:
  421. logger.error("ffmpeg not found - required for USB camera capture")
  422. return None
  423. safe_device = _safe_usb_device_path(device)
  424. if not safe_device:
  425. return None
  426. # Use the safe path for ffmpeg - this is a hardcoded /dev/videoN path
  427. device = safe_device # lgtm[py/path-injection]
  428. # Use ffmpeg to grab a single frame from USB camera
  429. cmd = [
  430. ffmpeg,
  431. "-f",
  432. "v4l2",
  433. "-i",
  434. device,
  435. "-frames:v",
  436. "1",
  437. "-f",
  438. "image2pipe",
  439. "-vcodec",
  440. "mjpeg",
  441. "-q:v",
  442. "2",
  443. "-",
  444. ]
  445. from backend.app.services.camera import _active_capture_pids
  446. process = None
  447. try:
  448. logger.debug("Running USB capture: %s", " ".join(cmd))
  449. process = await asyncio.create_subprocess_exec(
  450. *cmd,
  451. stdout=asyncio.subprocess.PIPE,
  452. stderr=asyncio.subprocess.PIPE,
  453. )
  454. # Registered like the Bambu one-shot capture, or the orphan janitor
  455. # (routes/camera.cleanup_orphaned_streams) kills it mid-capture.
  456. _active_capture_pids.add(process.pid)
  457. stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
  458. if process.returncode != 0:
  459. logger.error("ffmpeg USB capture failed: %s", summarize_ffmpeg_stderr(stderr) or NO_FFMPEG_OUTPUT)
  460. return None
  461. if not stdout or len(stdout) < 100:
  462. logger.error("ffmpeg returned empty or too small frame from USB camera")
  463. return None
  464. return stdout
  465. except TimeoutError:
  466. logger.warning("USB frame capture timed out after %ss", timeout)
  467. return None
  468. except OSError as e:
  469. logger.error("USB frame capture failed: %s", e)
  470. return None
  471. finally:
  472. # Killed however the capture ends, not only on its deadline (#3292).
  473. await reap_ffmpeg(process)
  474. if process is not None:
  475. _active_capture_pids.discard(process.pid)
  476. # Redirects a camera URL may take before the fetch gives up.
  477. _MAX_CAMERA_REDIRECTS = 5
  478. _REDIRECT_STATUSES = (301, 302, 303, 307, 308)
  479. def _with_same_camera_login(current: str, target: str) -> str:
  480. """*target* with the login of *current* when it is the same camera (scheme,
  481. host and port) and names no login of its own, as the HTTP client kept it."""
  482. here, there = urlparse(current), urlparse(target)
  483. if there.username or not here.username:
  484. return target
  485. if (here.scheme, here.hostname, here.port) != (there.scheme, there.hostname, there.port):
  486. return target
  487. return there._replace(netloc=f"{here.netloc.rsplit('@', 1)[0]}@{there.netloc}").geturl()
  488. @contextlib.asynccontextmanager
  489. async def _get_camera_url(session: aiohttp.ClientSession, url: str, schemes: tuple[str, ...]):
  490. """GET a sanitized camera URL, following redirects only where the camera
  491. guard allows: each destination is checked as the URL itself was. Yields
  492. the response, or None when a redirect goes somewhere refused or never ends.
  493. """
  494. current = url
  495. for _ in range(_MAX_CAMERA_REDIRECTS + 1):
  496. async with session.get(current, allow_redirects=False) as response:
  497. location = response.headers.get("Location") if response.status in _REDIRECT_STATUSES else None
  498. if location is None:
  499. yield response
  500. return
  501. try:
  502. target = _sanitize_camera_url(_with_same_camera_login(current, urljoin(current, location)), schemes)
  503. except ValueError:
  504. # A malformed Location (bad IPv6 literal, port out of range).
  505. target = None
  506. if target is None:
  507. logger.error(
  508. "Camera URL redirected to a refused destination: %s", (redact_url_credentials(location) or "")[:50]
  509. )
  510. yield None
  511. return
  512. current = target
  513. logger.error("Camera URL redirected more than %d times", _MAX_CAMERA_REDIRECTS)
  514. yield None
  515. async def _capture_mjpeg_frame(url: str, timeout: int) -> bytes | None:
  516. """Extract a single representative frame from an MJPEG stream.
  517. Many MJPEG sources — go2rtc most notably (#1177), and several IP cameras —
  518. emit a "warm-up" frame on the byte that follows connection accept: usually
  519. the last keyframe held in the encoder, which is often black or stale until
  520. the encoder catches up to live content. To return a frame that's actually
  521. representative of the scene we read past the first frame and return the
  522. second; if the connection closes / times out / hits the buffer cap before
  523. a second frame ever arrives we fall back to the first so callers still
  524. get *something* (better than degrading slow / single-frame streams to None,
  525. which would regress every code path that consumed pre-fix behaviour).
  526. Note: this function intentionally makes requests to user-configured URLs.
  527. External camera support requires connecting to user-specified camera
  528. endpoints. URL is sanitized and dangerous destinations are blocked.
  529. """
  530. safe_url = _sanitize_camera_url(url, ("http", "https"))
  531. if not safe_url:
  532. logger.error("Invalid MJPEG URL format: %s...", redact_url_credentials(url)[:50])
  533. return None
  534. jpeg_start = b"\xff\xd8"
  535. jpeg_end = b"\xff\xd9"
  536. first_frame: bytes | None = None # warm-up frame; fallback if no second arrives
  537. buffer = b""
  538. try:
  539. async with (
  540. asyncio.timeout(timeout),
  541. aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session,
  542. _get_camera_url(session, safe_url, ("http", "https")) as response,
  543. ):
  544. if response is None:
  545. return None
  546. if response.status != 200:
  547. logger.error("MJPEG stream returned status %s", response.status)
  548. return None
  549. async for chunk in response.content.iter_chunked(8192):
  550. buffer += chunk
  551. # A single chunk can carry multiple frames (e.g. high-FPS sources)
  552. # or a partial frame. Drain every complete frame we already have
  553. # before pulling the next chunk.
  554. while True:
  555. start_idx = buffer.find(jpeg_start)
  556. if start_idx == -1:
  557. # No frame start yet — drop trailing garbage, keep waiting.
  558. break
  559. end_idx = buffer.find(jpeg_end, start_idx + 2)
  560. if end_idx == -1:
  561. # Partial frame; trim already-discarded prefix so the
  562. # buffer stays bounded across long-running streams.
  563. if start_idx > 0:
  564. buffer = buffer[start_idx:]
  565. break
  566. frame = buffer[start_idx : end_idx + 2]
  567. buffer = buffer[end_idx + 2 :]
  568. if first_frame is None:
  569. first_frame = frame # warm-up; keep but don't return yet
  570. continue
  571. return frame # representative second frame
  572. if len(buffer) > 5 * 1024 * 1024: # 5MB limit
  573. logger.warning("MJPEG buffer exceeded 5MB without finding frame")
  574. break # exit chunk loop, fall through to first_frame fallback
  575. except TimeoutError:
  576. logger.warning("MJPEG frame capture timed out after %ss", timeout)
  577. except (aiohttp.ClientError, OSError) as e:
  578. logger.error("MJPEG frame capture failed: %s", e)
  579. # Stream ended / timed out / buffer cap before a second frame arrived.
  580. # Return whatever warm-up frame we managed to read; better an iffy frame
  581. # than None for callers that need *some* image (snapshot UX, plate-detect
  582. # CV, finish photo). None only if no frame ever arrived at all.
  583. return first_frame
  584. async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
  585. """Capture frame from RTSP using ffmpeg.
  586. For rtsps:// URLs, a local TLS proxy is used to avoid GnuTLS issues.
  587. Note: this function intentionally connects to user-configured URLs, the same
  588. as the MJPEG and snapshot paths. The URL is sanitized and dangerous
  589. destinations are blocked before it reaches ffmpeg.
  590. """
  591. ffmpeg = get_ffmpeg_path()
  592. if not ffmpeg:
  593. logger.error("ffmpeg not found - required for RTSP capture")
  594. return None
  595. # ffmpeg's -i accepts every protocol it was built with, so an unchecked URL
  596. # here is a request to any host and scheme the caller names, not merely to a
  597. # camera. Restricting the scheme to RTSP is what keeps this a camera fetch.
  598. safe_url = _sanitize_camera_url(url, ("rtsp", "rtsps"))
  599. if not safe_url:
  600. logger.error("Invalid RTSP URL: %s...", redact_url_credentials(url)[:50])
  601. return None
  602. # If rtsps://, use TLS proxy
  603. proxy_server = None
  604. effective_url = safe_url
  605. if safe_url.lower().startswith("rtsps://"):
  606. try:
  607. from urllib.parse import urlparse
  608. from backend.app.services.camera import close_tls_proxy, create_tls_proxy
  609. parsed = urlparse(safe_url)
  610. target_port = parsed.port or 322
  611. proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
  612. userinfo = ""
  613. if parsed.username:
  614. userinfo = parsed.username
  615. if parsed.password:
  616. userinfo += f":{parsed.password}"
  617. userinfo += "@"
  618. # Points at loopback deliberately, and is built after the check
  619. # above rather than re-checked: the destination that mattered was
  620. # the one the caller named, and it has already been vetted.
  621. effective_url = f"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}"
  622. if parsed.query:
  623. effective_url += f"?{parsed.query}"
  624. except Exception as e:
  625. logger.warning("Failed to create TLS proxy for RTSP capture, falling back: %s", e)
  626. effective_url = safe_url
  627. cmd = [
  628. ffmpeg,
  629. *FFMPEG_RTSP_LOGLEVEL,
  630. "-rtsp_transport",
  631. "tcp",
  632. # Belt and braces on the scheme check above: a demuxer that follows a
  633. # reference out of the stream cannot leave these protocols either.
  634. "-protocol_whitelist",
  635. _RTSP_PROTOCOL_WHITELIST,
  636. "-i",
  637. effective_url,
  638. "-frames:v",
  639. "1",
  640. "-f",
  641. "image2pipe",
  642. "-vcodec",
  643. "mjpeg",
  644. "-q:v",
  645. "2",
  646. "-",
  647. ]
  648. process = None
  649. try:
  650. logger.debug("Running ffmpeg RTSP capture...")
  651. process = await asyncio.create_subprocess_exec(
  652. *cmd,
  653. stdout=asyncio.subprocess.PIPE,
  654. stderr=asyncio.subprocess.PIPE,
  655. )
  656. async with asyncio.timeout(timeout):
  657. state, log = await read_ffmpeg_log_until_open(process)
  658. stdout, stderr = await process.communicate()
  659. stderr = log + stderr
  660. logger.debug(
  661. "ffmpeg returned: code=%s, stdout=%s bytes, stderr=%s bytes",
  662. process.returncode,
  663. len(stdout),
  664. len(stderr),
  665. )
  666. if state == "redirected":
  667. return None
  668. if process.returncode != 0 or state != "open":
  669. # The summariser masks the camera password the input URL carries.
  670. logger.error("ffmpeg RTSP capture failed: %s", summarize_ffmpeg_stderr(stderr) or NO_FFMPEG_OUTPUT)
  671. return None
  672. if not stdout or len(stdout) < 100:
  673. logger.error("ffmpeg returned empty or too small frame")
  674. return None
  675. return stdout
  676. except TimeoutError:
  677. logger.warning("RTSP frame capture timed out after %ss", timeout)
  678. return None
  679. except OSError as e:
  680. logger.error("RTSP frame capture failed: %s", e)
  681. return None
  682. finally:
  683. # Killed and waited for however the capture ends, not only on its
  684. # deadline (#3292).
  685. await reap_ffmpeg(process)
  686. if proxy_server:
  687. await close_tls_proxy(proxy_server)
  688. def _transcode_to_jpeg(data: bytes) -> bytes | None:
  689. """Decode an arbitrary still image (PNG/WebP/BMP/GIF/...) and re-encode as JPEG.
  690. Some camera/proxy snapshot endpoints serve stills as PNG or WebP rather than
  691. JPEG. A browser opened directly at the URL renders those fine, but our MJPEG
  692. ``multipart/x-mixed-replace`` stream hard-labels every part
  693. ``Content-Type: image/jpeg`` — so a non-JPEG payload makes the browser reject
  694. the frame and drop the whole stream ("connection lost", #1902). Transcoding to
  695. JPEG keeps the stream genuinely MJPEG and also keeps the JPEG-only downstream
  696. (plate detection, Obico, finish photo) working.
  697. Returns None if the bytes are not a decodable image (e.g. an HTML error page)
  698. or if the imaging libraries are unavailable; the caller then has no image.
  699. The raw bytes are not passed on: a URL's response is not a camera frame.
  700. """
  701. try:
  702. import cv2
  703. import numpy as np
  704. except ImportError:
  705. return None
  706. try:
  707. img = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_COLOR)
  708. if img is None:
  709. return None
  710. ok, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 85])
  711. if not ok:
  712. return None
  713. return buf.tobytes()
  714. except Exception as e: # cv2 raises cv2.error (a subclass of Exception) on bad input
  715. logger.debug("Snapshot transcode to JPEG failed: %s", e)
  716. return None
  717. async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
  718. """Fetch snapshot from HTTP URL.
  719. Note: This function intentionally makes requests to user-configured URLs.
  720. External camera support requires connecting to user-specified camera endpoints.
  721. URL is sanitized and dangerous destinations are blocked.
  722. """
  723. # Sanitize URL - returns reconstructed URL from validated components
  724. safe_url = _sanitize_camera_url(url, ("http", "https"))
  725. if not safe_url:
  726. logger.error("Invalid snapshot URL format: %s...", redact_url_credentials(url)[:50])
  727. return None
  728. try:
  729. # One deadline for every redirect and the read, as the client's own
  730. # timeout covered them when it followed redirects itself.
  731. async with (
  732. asyncio.timeout(timeout),
  733. aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session,
  734. _get_camera_url(session, safe_url, ("http", "https")) as response,
  735. ):
  736. if response is None:
  737. return None
  738. if response.status != 200:
  739. logger.error("Snapshot URL returned status %s", response.status)
  740. return None
  741. data = await response.read()
  742. except TimeoutError:
  743. logger.warning("Snapshot capture timed out after %ss", timeout)
  744. return None
  745. except (aiohttp.ClientError, OSError) as e:
  746. logger.error("Snapshot capture failed: %s", e)
  747. return None
  748. # Fast path: already JPEG (SOI marker), stream it as-is (no decode/re-encode).
  749. if data.startswith(b"\xff\xd8"):
  750. return data
  751. # Not JPEG. Many snapshot endpoints serve PNG/WebP/BMP — transcode to JPEG so
  752. # the browser's MJPEG stream (and JPEG-only downstream) keep working instead of
  753. # dropping the connection (#1902). Run off the event loop: cv2 decode/encode is
  754. # CPU-bound and this can be polled at up to 15 fps while a camera view is open.
  755. transcoded = await asyncio.to_thread(_transcode_to_jpeg, data)
  756. if transcoded is not None:
  757. logger.debug(
  758. "Transcoded non-JPEG snapshot (%d bytes, header %s) to JPEG",
  759. len(data),
  760. data[:4].hex(),
  761. )
  762. return transcoded
  763. # Couldn't decode it as an image at all — most likely not an image response
  764. # (HTML error page, auth redirect, wrong URL). Not passed on: whatever a URL
  765. # returns would reach the viewer of the camera stream.
  766. logger.warning(
  767. "External camera snapshot is not a decodable image "
  768. "(%d bytes, header %s) — verify the camera URL returns an image",
  769. len(data),
  770. data[:4].hex(),
  771. )
  772. return None
  773. def jpeg_resolution(frame: bytes) -> str | None:
  774. """``"WIDTHxHEIGHT"`` from a JPEG's SOF marker, or None when there is none.
  775. The SOF marker (FF C0/C1/C2) is followed by length, precision, height, width.
  776. """
  777. for marker in (b"\xff\xc0", b"\xff\xc1", b"\xff\xc2"):
  778. idx = frame.find(marker)
  779. if idx != -1 and idx + 9 <= len(frame):
  780. height = (frame[idx + 5] << 8) | frame[idx + 6]
  781. width = (frame[idx + 7] << 8) | frame[idx + 8]
  782. return f"{width}x{height}"
  783. return None
  784. async def test_connection(url: str, camera_type: str) -> dict:
  785. """Test camera connection.
  786. Returns:
  787. Dict with {success: bool, error?: str, resolution?: str, coalesced: bool}
  788. ``coalesced`` is True when the frame came from a capture that was already
  789. running rather than from a connection this test opened. Captures are shared
  790. (see ``capture_frame``), so a test that lands while Obico is polling — or
  791. while any other one-shot consumer is mid-capture — gets that frame back and
  792. would otherwise report a healthy connection it never made, which is the one
  793. answer a *connection test* must not give silently. Forcing an uncoalesced
  794. capture here would be worse: it would open the second handle to a
  795. single-reader device that this whole mechanism exists to prevent. So the
  796. test still shares, and says so. Mirrors the ``coalesced_capture`` code the
  797. built-in diagnostic reports for the same situation (camera_diagnose.py).
  798. """
  799. logger.info("Testing camera connection: type=%s, url=%s...", camera_type, redact_url_credentials(url)[:50])
  800. # Sampled before the call, while it can still distinguish "someone else is
  801. # mid-capture" from "I am the one capturing".
  802. coalesced = capture_in_flight(url, camera_type)
  803. try:
  804. frame = await capture_frame(url, camera_type, timeout=10)
  805. logger.info("Capture result: %s bytes%s", len(frame) if frame else 0, " (coalesced)" if coalesced else "")
  806. if frame:
  807. return {"success": True, "resolution": jpeg_resolution(frame), "coalesced": coalesced}
  808. else:
  809. return {"success": False, "error": "Failed to capture frame from camera", "coalesced": coalesced}
  810. except Exception as e:
  811. # Sanitize error message - don't expose internal details
  812. error_type = type(e).__name__
  813. logger.error("Camera connection test failed: %s", e)
  814. return {"success": False, "error": f"Connection failed: {error_type}", "coalesced": coalesced}
  815. async def generate_mjpeg_stream(
  816. url: str,
  817. camera_type: str,
  818. fps: int = 10,
  819. *,
  820. on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
  821. on_frame: Callable[[bytes], None] | None = None,
  822. stop_event: asyncio.Event | None = None,
  823. ) -> AsyncGenerator[bytes, None]:
  824. """Generator yielding MJPEG frames for streaming.
  825. Args:
  826. url: Camera URL or USB device path
  827. camera_type: "mjpeg", "rtsp", "snapshot", or "usb"
  828. fps: Target frames per second
  829. on_process: Called with the spawned ffmpeg process for the ``usb`` and
  830. ``rtsp`` paths so the route layer can register it into the shared
  831. stream registries — that's what lets ``/camera/stop`` and the orphan
  832. janitor find and kill a leaked ffmpeg that's holding a USB device
  833. open (#2675). Without it the process is reachable only from this
  834. generator's own ``finally``, which an abrupt client disconnect can
  835. skip (same cancellation-timing class as #776).
  836. on_frame: Called with each RAW frame, before it is wrapped for the wire,
  837. so the route layer can publish it as the printer's buffered frame
  838. (#2707). It has to be a callback: what this generator yields is
  839. multipart-wrapped, so a consumer of the stream cannot recover the
  840. JPEG, and until now nothing populated the buffer for external
  841. cameras at all — leaving every one-shot consumer (layer timelapse,
  842. finish photo, Obico, plate check) with nothing to reuse and no
  843. option but to open a competing handle on a single-reader device.
  844. Exceptions are logged and swallowed: buffering must never be able
  845. to break the live stream.
  846. stop_event: When set, the reconnect loops stop retrying — so an explicit
  847. stop (which kills the current ffmpeg) doesn't immediately respawn a
  848. new process and reacquire the device.
  849. Yields:
  850. MJPEG frame data with HTTP multipart boundaries
  851. """
  852. frame_interval = 1.0 / max(fps, 1)
  853. last_frame_time = 0.0
  854. def _publish(frame: bytes) -> bytes:
  855. """Hand the raw frame to on_frame, then format it for the wire."""
  856. if on_frame is not None:
  857. try:
  858. on_frame(frame)
  859. except Exception:
  860. logger.exception("on_frame callback raised")
  861. return _format_mjpeg_frame(frame)
  862. async def _reconnecting(open_session, label: str):
  863. """Yield frames across sessions, reconnecting after each that delivered.
  864. A session that ends without a single frame stops the stream: the
  865. source is down, and retrying is the viewer's call. One that delivered
  866. frames and then ended is a routine drop and is always reconnected.
  867. This used to be three reconnects for the life of the stream, so a
  868. server that closes its sessions periodically ended the stream for good
  869. on the fourth drop, however long each session had run -- the external
  870. twin of the built-in RTSP path's lifetime reconnect budget.
  871. """
  872. drops = 0
  873. while True:
  874. frame_yielded = False
  875. # aclosing: stop the session's ffmpeg the moment this generator is
  876. # closed, rather than whenever the abandoned iterator is collected.
  877. async with contextlib.aclosing(open_session()) as session:
  878. async for frame in session:
  879. frame_yielded = True
  880. yield frame
  881. if not frame_yielded or (stop_event is not None and stop_event.is_set()):
  882. break
  883. # The sessions swallow CancelledError and simply end, so a viewer
  884. # whose task was cancelled mid-read looks like a routine drop here.
  885. # Never redial for a task that is being cancelled (Task.cancelling
  886. # is 3.11+; without it this falls back to the next await raising).
  887. task = asyncio.current_task()
  888. if task is not None and getattr(task, "cancelling", lambda: 0)():
  889. break
  890. drops += 1
  891. logger.warning("External %s stream ended, reconnecting (drop %d)...", label, drops)
  892. await asyncio.sleep(2)
  893. if camera_type == "mjpeg":
  894. # Proxy MJPEG stream directly, reconnecting after routine drops.
  895. async with contextlib.aclosing(_reconnecting(lambda: _stream_mjpeg(url), "MJPEG")) as frames:
  896. async for frame in frames:
  897. current_time = asyncio.get_event_loop().time()
  898. if current_time - last_frame_time >= frame_interval:
  899. last_frame_time = current_time
  900. yield _publish(frame)
  901. elif camera_type == "rtsp":
  902. # Use ffmpeg to convert RTSP to MJPEG, reconnecting after routine drops.
  903. # One freeze watch for the whole stream: the still-scene rule compares a
  904. # new session's first frame with the one the last session froze on.
  905. freeze = _FreezeWatch()
  906. async with contextlib.aclosing(
  907. _reconnecting(lambda: _stream_rtsp(url, fps, on_process=on_process, freeze=freeze), "RTSP")
  908. ) as frames:
  909. async for frame in frames:
  910. yield _publish(frame)
  911. elif camera_type == "usb":
  912. # Use ffmpeg to stream from USB camera
  913. async for frame in _stream_usb(url, fps, on_process=on_process):
  914. yield _publish(frame)
  915. elif camera_type == "snapshot":
  916. # Poll snapshot URL at interval
  917. while True:
  918. try:
  919. frame = await _capture_snapshot(url, timeout=10)
  920. if frame:
  921. yield _publish(frame)
  922. await asyncio.sleep(frame_interval)
  923. except asyncio.CancelledError:
  924. break
  925. except (aiohttp.ClientError, OSError) as e:
  926. logger.warning("Snapshot poll failed: %s", e)
  927. await asyncio.sleep(frame_interval)
  928. def _format_mjpeg_frame(frame: bytes) -> bytes:
  929. """Format frame for MJPEG HTTP response."""
  930. return (
  931. b"--frame\r\n"
  932. b"Content-Type: image/jpeg\r\n"
  933. b"Content-Length: " + str(len(frame)).encode() + b"\r\n"
  934. b"\r\n" + frame + b"\r\n"
  935. )
  936. async def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]:
  937. """Stream frames from MJPEG URL.
  938. Note: This function intentionally makes requests to user-configured URLs.
  939. External camera support requires connecting to user-specified camera endpoints.
  940. URL is sanitized and dangerous destinations are blocked.
  941. """
  942. # Sanitize URL - returns reconstructed URL from validated components
  943. safe_url = _sanitize_camera_url(url, ("http", "https"))
  944. if not safe_url:
  945. logger.error("Invalid MJPEG stream URL: %s...", redact_url_credentials(url)[:50])
  946. return
  947. try:
  948. timeout = aiohttp.ClientTimeout(total=None, sock_read=30)
  949. async with (
  950. aiohttp.ClientSession(timeout=timeout) as session,
  951. _get_camera_url(session, safe_url, ("http", "https")) as response,
  952. ):
  953. if response is None:
  954. return
  955. if response.status != 200:
  956. logger.error("MJPEG stream returned status %s", response.status)
  957. return
  958. buffer = b""
  959. jpeg_start = b"\xff\xd8"
  960. jpeg_end = b"\xff\xd9"
  961. async for chunk in response.content.iter_chunked(8192):
  962. buffer += chunk
  963. # Extract complete frames from buffer
  964. while True:
  965. start_idx = buffer.find(jpeg_start)
  966. if start_idx == -1:
  967. buffer = buffer[-2:] if len(buffer) > 2 else buffer
  968. break
  969. if start_idx > 0:
  970. buffer = buffer[start_idx:]
  971. end_idx = buffer.find(jpeg_end, 2)
  972. if end_idx == -1:
  973. break
  974. frame = buffer[: end_idx + 2]
  975. buffer = buffer[end_idx + 2 :]
  976. yield frame
  977. except asyncio.CancelledError:
  978. logger.info("MJPEG stream cancelled")
  979. except (aiohttp.ClientError, OSError) as e:
  980. logger.error("MJPEG stream error: %s", e)
  981. # How long an external RTSP stream may emit byte-identical frames before its
  982. # ffmpeg is restarted, and the longer re-check for a picture that really does
  983. # not change: the same rule as the printer's own RTSP stream (#3218, see
  984. # routes/camera._RTSP_FROZEN_SECONDS). ffmpeg's ``-r fps`` repeats its last
  985. # frame after the camera stops sending, so counting frames sees a live stream.
  986. _EXTERNAL_RTSP_FROZEN_SECONDS = 20.0
  987. _EXTERNAL_RTSP_STILL_RECHECK_SECONDS = 300.0
  988. class _FreezeWatch:
  989. """Notices an RTSP picture that has stopped changing, across sessions.
  990. A live camera rarely repeats a frame byte for byte (sensor noise), so a run
  991. of identical frames usually means ffmpeg is repeating a frozen one. When the
  992. first frame after the restart is that same picture again, the camera really
  993. shows it (a dark, idle scene), and it is only re-checked at the longer
  994. interval until it changes.
  995. Unlike a Bambu camera, an external one can legitimately repeat frames for a
  996. while: a source slower than ffmpeg's ``-r`` rate, or a surveillance camera's
  997. smart codec on a static scene. So the limit is three times the longest gap
  998. seen between real changes (a "freeze" after which the camera showed a new
  999. picture counts as one), never below the 20 s of the printer's camera and
  1000. never above the still-scene interval.
  1001. """
  1002. def __init__(self) -> None:
  1003. self.frozen_crc: int | None = None
  1004. self.still_scene = False
  1005. self.unchanged_for = 0.0
  1006. self._last_crc: int | None = None
  1007. self._since = 0.0
  1008. self._longest_gap = 0.0
  1009. def start_session(self) -> None:
  1010. self._last_crc = None
  1011. def _limit(self) -> float:
  1012. if self.still_scene:
  1013. return _EXTERNAL_RTSP_STILL_RECHECK_SECONDS
  1014. return min(
  1015. max(_EXTERNAL_RTSP_FROZEN_SECONDS, 3 * self._longest_gap),
  1016. _EXTERNAL_RTSP_STILL_RECHECK_SECONDS,
  1017. )
  1018. def frozen(self, frame: bytes, now: float) -> bool:
  1019. crc = zlib.crc32(frame)
  1020. if self._last_crc is None:
  1021. if self.frozen_crc is not None and crc != self.frozen_crc:
  1022. # The picture had not frozen: the camera is just slow to change.
  1023. self._longest_gap = max(self._longest_gap, self.unchanged_for)
  1024. self.still_scene = self.frozen_crc is not None and crc == self.frozen_crc
  1025. self._last_crc = crc
  1026. self._since = now
  1027. return False
  1028. if crc != self._last_crc:
  1029. self._longest_gap = max(self._longest_gap, now - self._since)
  1030. self._last_crc = crc
  1031. self._since = now
  1032. self.still_scene = False
  1033. self.frozen_crc = None
  1034. return False
  1035. self.unchanged_for = now - self._since
  1036. if self.unchanged_for > self._limit():
  1037. self.frozen_crc = crc
  1038. return True
  1039. return False
  1040. async def _end_stream_ffmpeg(process: asyncio.subprocess.Process) -> None:
  1041. """Stop a streaming ffmpeg, bounded.
  1042. Through the camera routes' terminator, which drains the pipes while it
  1043. waits: a streaming ffmpeg is usually blocked writing to a full stdout pipe,
  1044. where it ignores SIGTERM and, once killed, wait() never returns (#2580).
  1045. Unbounded, that hung the shared upstream's teardown, and with it every stop,
  1046. camera change, printer delete and shutdown waiting on the fan-out.
  1047. """
  1048. from backend.app.api.routes.camera import _terminate_ffmpeg
  1049. await _terminate_ffmpeg(process)
  1050. async def _stream_rtsp(
  1051. url: str,
  1052. fps: int,
  1053. *,
  1054. on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
  1055. freeze: _FreezeWatch | None = None,
  1056. ) -> AsyncGenerator[bytes, None]:
  1057. """Stream frames from RTSP URL via ffmpeg.
  1058. For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead
  1059. of relying on ffmpeg's GnuTLS backend, which has compatibility issues
  1060. with some printer firmwares.
  1061. Note: this function intentionally connects to user-configured URLs. The URL
  1062. is sanitized and dangerous destinations are blocked before it reaches
  1063. ffmpeg — see ``_capture_rtsp_frame``, which guards the one-shot path the
  1064. same way.
  1065. """
  1066. ffmpeg = get_ffmpeg_path()
  1067. if not ffmpeg:
  1068. logger.error("ffmpeg not found - required for RTSP streaming")
  1069. return
  1070. from backend.app.services.camera import rtsp_socket_timeout_flag
  1071. safe_url = _sanitize_camera_url(url, ("rtsp", "rtsps"))
  1072. if not safe_url:
  1073. logger.error("Invalid RTSP stream URL: %s...", redact_url_credentials(url)[:50])
  1074. return
  1075. # If the URL uses rtsps://, set up a TLS proxy so ffmpeg uses plain rtsp://
  1076. proxy_server = None
  1077. effective_url = safe_url
  1078. if safe_url.lower().startswith("rtsps://"):
  1079. try:
  1080. from urllib.parse import urlparse
  1081. from backend.app.services.camera import close_tls_proxy, create_tls_proxy
  1082. parsed = urlparse(safe_url)
  1083. target_port = parsed.port or 322
  1084. proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
  1085. # Rewrite URL: rtsps://user:pass@host:port/path → rtsp://user:pass@127.0.0.1:proxy/path
  1086. userinfo = ""
  1087. if parsed.username:
  1088. userinfo = parsed.username
  1089. if parsed.password:
  1090. userinfo += f":{parsed.password}"
  1091. userinfo += "@"
  1092. # Loopback by design, and built after the check above rather than
  1093. # re-checked — see the same rewrite in _capture_rtsp_frame.
  1094. effective_url = f"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}"
  1095. if parsed.query:
  1096. effective_url += f"?{parsed.query}"
  1097. except Exception as e:
  1098. logger.warning("Failed to create TLS proxy for RTSP, falling back to direct: %s", e)
  1099. effective_url = safe_url
  1100. cmd = [
  1101. ffmpeg,
  1102. *FFMPEG_RTSP_LOGLEVEL,
  1103. "-rtsp_transport",
  1104. "tcp",
  1105. "-rtsp_flags",
  1106. "prefer_tcp",
  1107. "-protocol_whitelist",
  1108. _RTSP_PROTOCOL_WHITELIST,
  1109. # Socket I/O timeout name varies by ffmpeg version (#1504); see
  1110. # `rtsp_socket_timeout_flag()` in services.camera.
  1111. f"-{rtsp_socket_timeout_flag()}",
  1112. "30000000",
  1113. "-buffer_size",
  1114. "1024000",
  1115. "-max_delay",
  1116. "500000",
  1117. # No probe cap here (#3082). The input is whatever camera the user
  1118. # owns, so there is no stream to tune a fast-start probe against: a
  1119. # 32-byte probe expires before a source that carries SPS/PPS in-band
  1120. # rather than in its SDP has sent them, and ffmpeg then starts no
  1121. # H.264 decoder and emits nothing at all. ffmpeg's defaults are a
  1122. # ceiling rather than a wait, so a camera that announces itself in the
  1123. # first packet still starts as fast as it ever did.
  1124. #
  1125. # `_capture_rtsp_frame` has always run on those defaults, which is how
  1126. # a camera could pass the connection test and still show a black live
  1127. # view. The printer path is the opposite case — a known Bambu camera
  1128. # per model — and keeps its tuning in `camera_profiles.py`.
  1129. "-fflags",
  1130. "nobuffer",
  1131. "-flags",
  1132. "low_delay",
  1133. "-i",
  1134. effective_url,
  1135. "-f",
  1136. "mjpeg",
  1137. "-q:v",
  1138. "5",
  1139. "-r",
  1140. str(fps),
  1141. "-an",
  1142. "-",
  1143. ]
  1144. process = None
  1145. log_watch = None
  1146. try:
  1147. process = await asyncio.create_subprocess_exec(
  1148. *cmd,
  1149. stdout=asyncio.subprocess.PIPE,
  1150. stderr=asyncio.subprocess.PIPE,
  1151. )
  1152. # Register immediately — before the startup probe below — so a process
  1153. # that hangs on connect (rather than exiting) is still reachable by the
  1154. # stop endpoint / orphan janitor (#2675).
  1155. if on_process is not None:
  1156. on_process(process)
  1157. # No frame is read before ffmpeg reports the input open: a redirect it
  1158. # followed on the way would be in its log by then.
  1159. try:
  1160. state, log = await asyncio.wait_for(read_ffmpeg_log_until_open(process), timeout=30.0)
  1161. except TimeoutError:
  1162. logger.warning("RTSP stream did not open within 30s")
  1163. return
  1164. if state == "ended":
  1165. assert process.stderr is not None
  1166. log += await process.stderr.read()
  1167. # The summariser masks the camera password the input URL carries.
  1168. logger.error("ffmpeg RTSP stream failed: %s", summarize_ffmpeg_stderr(log) or NO_FFMPEG_OUTPUT)
  1169. return
  1170. if state != "open":
  1171. return
  1172. log_watch = asyncio.create_task(watch_ffmpeg_log(process))
  1173. if freeze is not None:
  1174. freeze.start_session()
  1175. buffer = b""
  1176. jpeg_start = b"\xff\xd8"
  1177. jpeg_end = b"\xff\xd9"
  1178. while True:
  1179. try:
  1180. chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=30.0)
  1181. if not chunk:
  1182. break
  1183. buffer += chunk
  1184. # Extract complete frames
  1185. while True:
  1186. start_idx = buffer.find(jpeg_start)
  1187. if start_idx == -1:
  1188. buffer = buffer[-2:] if len(buffer) > 2 else buffer
  1189. break
  1190. if start_idx > 0:
  1191. buffer = buffer[start_idx:]
  1192. end_idx = buffer.find(jpeg_end, 2)
  1193. if end_idx == -1:
  1194. break
  1195. frame = buffer[: end_idx + 2]
  1196. buffer = buffer[end_idx + 2 :]
  1197. yield frame
  1198. if freeze is not None and freeze.frozen(frame, time.monotonic()):
  1199. # Ending the session restarts ffmpeg (_reconnecting).
  1200. if freeze.still_scene:
  1201. logger.info("External RTSP still-scene re-check, restarting ffmpeg")
  1202. else:
  1203. logger.warning(
  1204. "External RTSP picture frozen for %.0fs, restarting ffmpeg",
  1205. freeze.unchanged_for,
  1206. )
  1207. return
  1208. except TimeoutError:
  1209. logger.warning("RTSP stream read timeout")
  1210. break
  1211. except asyncio.CancelledError:
  1212. logger.info("RTSP stream cancelled")
  1213. except OSError as e:
  1214. logger.error("RTSP stream error: %s", e)
  1215. finally:
  1216. if log_watch is not None:
  1217. log_watch.cancel()
  1218. if process and process.returncode is None:
  1219. await _end_stream_ffmpeg(process)
  1220. if proxy_server:
  1221. await close_tls_proxy(proxy_server)
  1222. async def _stream_usb(
  1223. device: str,
  1224. fps: int,
  1225. *,
  1226. on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
  1227. ) -> AsyncGenerator[bytes, None]:
  1228. """Stream frames from USB camera via ffmpeg."""
  1229. ffmpeg = get_ffmpeg_path()
  1230. if not ffmpeg:
  1231. logger.error("ffmpeg not found - required for USB camera streaming")
  1232. return
  1233. # Same validation as the one-shot path: a prefix check accepted
  1234. # /dev/video/../../<anything that exists>, which -f v4l2 would then refuse
  1235. # rather than the check refusing it.
  1236. safe_device = _safe_usb_device_path(device)
  1237. if not safe_device:
  1238. return
  1239. device = safe_device
  1240. # ffmpeg command to stream from USB camera (v4l2)
  1241. cmd = [
  1242. ffmpeg,
  1243. "-f",
  1244. "v4l2",
  1245. "-framerate",
  1246. str(fps),
  1247. "-i",
  1248. device,
  1249. "-f",
  1250. "mjpeg",
  1251. "-q:v",
  1252. "5",
  1253. "-r",
  1254. str(fps),
  1255. "-",
  1256. ]
  1257. process = None
  1258. try:
  1259. logger.info("Starting USB camera stream from %s at %s fps", device, fps)
  1260. process = await asyncio.create_subprocess_exec(
  1261. *cmd,
  1262. stdout=asyncio.subprocess.PIPE,
  1263. stderr=asyncio.subprocess.PIPE,
  1264. )
  1265. # Register immediately — before the startup probe below — so a process
  1266. # that hangs in open()/ioctl on a still-locked device (rather than
  1267. # exiting with a "busy" error) is still reachable by the stop endpoint /
  1268. # orphan janitor (#2675).
  1269. if on_process is not None:
  1270. on_process(process)
  1271. # Give ffmpeg a moment to start and check for immediate failures
  1272. await asyncio.sleep(0.5)
  1273. if process.returncode is not None:
  1274. stderr = await process.stderr.read()
  1275. logger.error(
  1276. "ffmpeg USB stream failed immediately: %s", summarize_ffmpeg_stderr(stderr) or NO_FFMPEG_OUTPUT
  1277. )
  1278. return
  1279. buffer = b""
  1280. jpeg_start = b"\xff\xd8"
  1281. jpeg_end = b"\xff\xd9"
  1282. while True:
  1283. try:
  1284. chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=30.0)
  1285. if not chunk:
  1286. break
  1287. buffer += chunk
  1288. # Extract complete frames
  1289. while True:
  1290. start_idx = buffer.find(jpeg_start)
  1291. if start_idx == -1:
  1292. buffer = buffer[-2:] if len(buffer) > 2 else buffer
  1293. break
  1294. if start_idx > 0:
  1295. buffer = buffer[start_idx:]
  1296. end_idx = buffer.find(jpeg_end, 2)
  1297. if end_idx == -1:
  1298. break
  1299. frame = buffer[: end_idx + 2]
  1300. buffer = buffer[end_idx + 2 :]
  1301. yield frame
  1302. except TimeoutError:
  1303. logger.warning("USB stream read timeout")
  1304. break
  1305. except asyncio.CancelledError:
  1306. logger.info("USB stream cancelled")
  1307. except OSError as e:
  1308. logger.error("USB stream error: %s", e)
  1309. finally:
  1310. if process and process.returncode is None:
  1311. await _end_stream_ffmpeg(process)