external_camera.py 44 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157
  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", key[0])
  186. async def capture_frame(
  187. url: str,
  188. camera_type: str,
  189. timeout: int = 15,
  190. snapshot_url: str | None = None,
  191. ) -> bytes | None:
  192. """Capture single frame from external camera.
  193. Args:
  194. url: Live-stream URL (MJPEG stream, RTSP URL, HTTP snapshot URL, or USB device path).
  195. camera_type: "mjpeg", "rtsp", "snapshot", or "usb".
  196. timeout: Connection timeout in seconds. Applies to this caller's own
  197. wait, including when it joins another caller's capture - call
  198. sites disagree about the value, and a follower must not silently
  199. inherit the leader's deadline in either direction.
  200. snapshot_url: Optional override for single-frame capture. When set, fetched
  201. via plain HTTP GET regardless of `camera_type`. Bypasses MJPEG warm-up
  202. handling on sources that expose a dedicated frame endpoint (e.g. go2rtc's
  203. `/api/frame.jpeg` reliably returns a clean image while the MJPEG stream's
  204. first frame is often the encoder's stale keyframe). #1177.
  205. Returns:
  206. JPEG bytes or None on failure
  207. Concurrent callers for the same (url, camera_type, snapshot_url) share
  208. one capture (#2705-shape fix, filed for the external-camera path as a
  209. follow-up on #2707): the first opens the connection, everyone arriving
  210. while it's in flight awaits the same result. This coalesces; it does
  211. not cache - a call that arrives after the previous capture finished
  212. always captures fresh, since plate detection and the finish-photo path
  213. judge a running print from these frames and a stale one there is worse
  214. than a slow one (#1397).
  215. """
  216. key = (url, camera_type, snapshot_url)
  217. # A follower whose leader fails takes a turn of its own rather than
  218. # inheriting a failure it never had a chance to avoid - by then the
  219. # leader has finished, so there's no connection left to compete with.
  220. # Bounded at two rounds: if the capture we joined AND its replacement
  221. # both failed, a third attempt won't help, and this caller has already
  222. # spent its patience.
  223. for _ in range(2):
  224. leader = _inflight_captures.get(key)
  225. if leader is None or leader.done():
  226. break
  227. try:
  228. frame = await asyncio.wait_for(asyncio.shield(leader), timeout=timeout)
  229. except TimeoutError:
  230. # shield() keeps the capture running for whoever else is still
  231. # waiting on it - giving up is this caller's decision alone.
  232. logger.warning("Gave up waiting %ss on the in-flight external-camera capture for %s", timeout, key[0])
  233. return None
  234. except asyncio.CancelledError:
  235. # Distinguish "the capture I joined was cancelled" from "I was
  236. # cancelled". Only the former is ours to recover from.
  237. if not leader.cancelled():
  238. raise
  239. logger.info("In-flight external-camera capture for %s was cancelled; capturing our own", key[0])
  240. continue
  241. if frame is not None:
  242. logger.debug(
  243. "Reusing in-flight external-camera capture for %s: %d bytes (no second connection opened)",
  244. key[0],
  245. len(frame),
  246. )
  247. return frame
  248. logger.debug("In-flight external-camera capture for %s failed; capturing our own", key[0])
  249. else:
  250. return None
  251. task = asyncio.create_task(_capture_frame_uncoalesced(url, camera_type, timeout, snapshot_url))
  252. _inflight_captures[key] = task
  253. task.add_done_callback(functools.partial(_discard_inflight_capture, key))
  254. # No wait_for here: this caller IS the capture, and each dispatched
  255. # _capture_* function already enforces `timeout` internally, where it
  256. # can also kill the ffmpeg process - a second deadline on top would
  257. # abandon the subprocess instead of killing it. shield() so a cancelled
  258. # leader (a client navigating away mid-request is routine) doesn't take
  259. # the capture down with it - followers already waiting on it still get
  260. # their frame.
  261. return await asyncio.shield(task)
  262. async def _capture_frame_uncoalesced(
  263. url: str,
  264. camera_type: str,
  265. timeout: int,
  266. snapshot_url: str | None,
  267. ) -> bytes | None:
  268. """Open a connection and capture one frame. See capture_frame().
  269. Callers want that wrapper, not this: it opens a connection
  270. unconditionally, which is the collision #2705/#2707 are about.
  271. """
  272. if snapshot_url:
  273. # Redact before truncating — slicing first can cut the URL short of the
  274. # ``@`` the pattern anchors on and leave the password in the log.
  275. logger.debug("capture_frame using snapshot override url=%s...", redact_url_credentials(snapshot_url)[:50])
  276. return await _capture_snapshot(snapshot_url, timeout)
  277. logger.debug(
  278. "capture_frame called: type=%s, url=%s...",
  279. camera_type,
  280. redact_url_credentials(url)[:50] if url else "None",
  281. )
  282. if camera_type == "mjpeg":
  283. return await _capture_mjpeg_frame(url, timeout)
  284. elif camera_type == "rtsp":
  285. return await _capture_rtsp_frame(url, timeout)
  286. elif camera_type == "snapshot":
  287. return await _capture_snapshot(url, timeout)
  288. elif camera_type == "usb":
  289. return await _capture_usb_frame(url, timeout)
  290. else:
  291. logger.warning("Unknown camera type: %s", camera_type)
  292. return None
  293. async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
  294. """Capture frame from USB camera using ffmpeg."""
  295. ffmpeg = get_ffmpeg_path()
  296. if not ffmpeg:
  297. logger.error("ffmpeg not found - required for USB camera capture")
  298. return None
  299. # Validate device path - must be /dev/videoN format where N is 0-99
  300. # This prevents path traversal by using a strict allowlist approach
  301. import re as regex_module
  302. device_match = regex_module.match(r"^/dev/video(\d{1,2})$", device)
  303. if not device_match:
  304. logger.error("Invalid USB device path format: %s", device)
  305. return None
  306. # Convert to integer to break taint chain - integers cannot contain path traversal
  307. # lgtm[py/path-injection] - device_num is validated integer 0-99
  308. device_num = int(device_match.group(1)) # Safe: regex guarantees 1-2 digits
  309. if device_num > 99:
  310. logger.error("USB device number out of range: %s", device_num)
  311. return None
  312. # Construct safe path from validated integer (completely untainted)
  313. safe_device_path = Path(f"/dev/video{device_num}") # lgtm[py/path-injection]
  314. if not safe_device_path.exists():
  315. logger.error("USB device does not exist: %s", safe_device_path)
  316. return None
  317. # Use the safe path for ffmpeg - this is a hardcoded /dev/videoN path
  318. device = str(safe_device_path) # lgtm[py/path-injection]
  319. # Use ffmpeg to grab a single frame from USB camera
  320. cmd = [
  321. ffmpeg,
  322. "-f",
  323. "v4l2",
  324. "-i",
  325. device,
  326. "-frames:v",
  327. "1",
  328. "-f",
  329. "image2pipe",
  330. "-vcodec",
  331. "mjpeg",
  332. "-q:v",
  333. "2",
  334. "-",
  335. ]
  336. try:
  337. logger.debug("Running USB capture: %s", " ".join(cmd))
  338. process = await asyncio.create_subprocess_exec(
  339. *cmd,
  340. stdout=asyncio.subprocess.PIPE,
  341. stderr=asyncio.subprocess.PIPE,
  342. )
  343. stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
  344. if process.returncode != 0:
  345. logger.error("ffmpeg USB capture failed: %s", stderr.decode()[:200])
  346. return None
  347. if not stdout or len(stdout) < 100:
  348. logger.error("ffmpeg returned empty or too small frame from USB camera")
  349. return None
  350. return stdout
  351. except TimeoutError:
  352. logger.warning("USB frame capture timed out after %ss", timeout)
  353. if process:
  354. process.kill()
  355. return None
  356. except OSError as e:
  357. logger.error("USB frame capture failed: %s", e)
  358. return None
  359. async def _capture_mjpeg_frame(url: str, timeout: int) -> bytes | None:
  360. """Extract a single representative frame from an MJPEG stream.
  361. Many MJPEG sources — go2rtc most notably (#1177), and several IP cameras —
  362. emit a "warm-up" frame on the byte that follows connection accept: usually
  363. the last keyframe held in the encoder, which is often black or stale until
  364. the encoder catches up to live content. To return a frame that's actually
  365. representative of the scene we read past the first frame and return the
  366. second; if the connection closes / times out / hits the buffer cap before
  367. a second frame ever arrives we fall back to the first so callers still
  368. get *something* (better than degrading slow / single-frame streams to None,
  369. which would regress every code path that consumed pre-fix behaviour).
  370. Note: this function intentionally makes requests to user-configured URLs.
  371. External camera support requires connecting to user-specified camera
  372. endpoints. URL is sanitized and dangerous destinations are blocked.
  373. """
  374. safe_url = _sanitize_camera_url(url, ("http", "https"))
  375. if not safe_url:
  376. logger.error("Invalid MJPEG URL format: %s...", redact_url_credentials(url)[:50])
  377. return None
  378. jpeg_start = b"\xff\xd8"
  379. jpeg_end = b"\xff\xd9"
  380. first_frame: bytes | None = None # warm-up frame; fallback if no second arrives
  381. buffer = b""
  382. try:
  383. async with (
  384. aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session,
  385. session.get(safe_url) as response,
  386. ):
  387. if response.status != 200:
  388. logger.error("MJPEG stream returned status %s", response.status)
  389. return None
  390. async for chunk in response.content.iter_chunked(8192):
  391. buffer += chunk
  392. # A single chunk can carry multiple frames (e.g. high-FPS sources)
  393. # or a partial frame. Drain every complete frame we already have
  394. # before pulling the next chunk.
  395. while True:
  396. start_idx = buffer.find(jpeg_start)
  397. if start_idx == -1:
  398. # No frame start yet — drop trailing garbage, keep waiting.
  399. break
  400. end_idx = buffer.find(jpeg_end, start_idx + 2)
  401. if end_idx == -1:
  402. # Partial frame; trim already-discarded prefix so the
  403. # buffer stays bounded across long-running streams.
  404. if start_idx > 0:
  405. buffer = buffer[start_idx:]
  406. break
  407. frame = buffer[start_idx : end_idx + 2]
  408. buffer = buffer[end_idx + 2 :]
  409. if first_frame is None:
  410. first_frame = frame # warm-up; keep but don't return yet
  411. continue
  412. return frame # representative second frame
  413. if len(buffer) > 5 * 1024 * 1024: # 5MB limit
  414. logger.warning("MJPEG buffer exceeded 5MB without finding frame")
  415. break # exit chunk loop, fall through to first_frame fallback
  416. except TimeoutError:
  417. logger.warning("MJPEG frame capture timed out after %ss", timeout)
  418. except (aiohttp.ClientError, OSError) as e:
  419. logger.error("MJPEG frame capture failed: %s", e)
  420. # Stream ended / timed out / buffer cap before a second frame arrived.
  421. # Return whatever warm-up frame we managed to read; better an iffy frame
  422. # than None for callers that need *some* image (snapshot UX, plate-detect
  423. # CV, finish photo). None only if no frame ever arrived at all.
  424. return first_frame
  425. async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
  426. """Capture frame from RTSP using ffmpeg.
  427. For rtsps:// URLs, a local TLS proxy is used to avoid GnuTLS issues.
  428. """
  429. ffmpeg = get_ffmpeg_path()
  430. if not ffmpeg:
  431. logger.error("ffmpeg not found - required for RTSP capture")
  432. return None
  433. # If rtsps://, use TLS proxy
  434. proxy_server = None
  435. effective_url = url
  436. if url.lower().startswith("rtsps://"):
  437. try:
  438. from urllib.parse import urlparse
  439. from backend.app.services.camera import create_tls_proxy
  440. parsed = urlparse(url)
  441. target_port = parsed.port or 322
  442. proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
  443. userinfo = ""
  444. if parsed.username:
  445. userinfo = parsed.username
  446. if parsed.password:
  447. userinfo += f":{parsed.password}"
  448. userinfo += "@"
  449. effective_url = f"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}"
  450. if parsed.query:
  451. effective_url += f"?{parsed.query}"
  452. except Exception as e:
  453. logger.warning("Failed to create TLS proxy for RTSP capture, falling back: %s", e)
  454. effective_url = url
  455. cmd = [
  456. ffmpeg,
  457. "-rtsp_transport",
  458. "tcp",
  459. "-i",
  460. effective_url,
  461. "-frames:v",
  462. "1",
  463. "-f",
  464. "image2pipe",
  465. "-vcodec",
  466. "mjpeg",
  467. "-q:v",
  468. "2",
  469. "-",
  470. ]
  471. try:
  472. logger.debug("Running ffmpeg RTSP capture...")
  473. process = await asyncio.create_subprocess_exec(
  474. *cmd,
  475. stdout=asyncio.subprocess.PIPE,
  476. stderr=asyncio.subprocess.PIPE,
  477. )
  478. stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
  479. logger.debug(
  480. "ffmpeg returned: code=%s, stdout=%s bytes, stderr=%s bytes",
  481. process.returncode,
  482. len(stdout),
  483. len(stderr),
  484. )
  485. if process.returncode != 0:
  486. # ffmpeg echoes the RTSP input URL, which carries the camera password.
  487. logger.error("ffmpeg RTSP capture failed: %s", redact_url_credentials(stderr.decode())[:200])
  488. return None
  489. if not stdout or len(stdout) < 100:
  490. logger.error("ffmpeg returned empty or too small frame")
  491. return None
  492. return stdout
  493. except TimeoutError:
  494. logger.warning("RTSP frame capture timed out after %ss", timeout)
  495. if process:
  496. process.kill()
  497. return None
  498. except OSError as e:
  499. logger.error("RTSP frame capture failed: %s", e)
  500. return None
  501. finally:
  502. if proxy_server:
  503. proxy_server.close()
  504. await proxy_server.wait_closed()
  505. def _transcode_to_jpeg(data: bytes) -> bytes | None:
  506. """Decode an arbitrary still image (PNG/WebP/BMP/GIF/...) and re-encode as JPEG.
  507. Some camera/proxy snapshot endpoints serve stills as PNG or WebP rather than
  508. JPEG. A browser opened directly at the URL renders those fine, but our MJPEG
  509. ``multipart/x-mixed-replace`` stream hard-labels every part
  510. ``Content-Type: image/jpeg`` — so a non-JPEG payload makes the browser reject
  511. the frame and drop the whole stream ("connection lost", #1902). Transcoding to
  512. JPEG keeps the stream genuinely MJPEG and also keeps the JPEG-only downstream
  513. (plate detection, Obico, finish photo) working.
  514. Returns None if the bytes are not a decodable image (e.g. an HTML error page)
  515. or if the imaging libraries are unavailable — callers fall back to the raw
  516. bytes so behaviour is never worse than before.
  517. """
  518. try:
  519. import cv2
  520. import numpy as np
  521. except ImportError:
  522. return None
  523. try:
  524. img = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_COLOR)
  525. if img is None:
  526. return None
  527. ok, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 85])
  528. if not ok:
  529. return None
  530. return buf.tobytes()
  531. except Exception as e: # cv2 raises cv2.error (a subclass of Exception) on bad input
  532. logger.debug("Snapshot transcode to JPEG failed: %s", e)
  533. return None
  534. async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
  535. """Fetch snapshot from HTTP URL.
  536. Note: This function intentionally makes requests to user-configured URLs.
  537. External camera support requires connecting to user-specified camera endpoints.
  538. URL is sanitized and dangerous destinations are blocked.
  539. """
  540. # Sanitize URL - returns reconstructed URL from validated components
  541. safe_url = _sanitize_camera_url(url, ("http", "https"))
  542. if not safe_url:
  543. logger.error("Invalid snapshot URL format: %s...", redact_url_credentials(url)[:50])
  544. return None
  545. try:
  546. async with (
  547. aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session,
  548. session.get(safe_url) as response,
  549. ):
  550. if response.status != 200:
  551. logger.error("Snapshot URL returned status %s", response.status)
  552. return None
  553. data = await response.read()
  554. except TimeoutError:
  555. logger.warning("Snapshot capture timed out after %ss", timeout)
  556. return None
  557. except (aiohttp.ClientError, OSError) as e:
  558. logger.error("Snapshot capture failed: %s", e)
  559. return None
  560. # Fast path: already JPEG (SOI marker), stream it as-is (no decode/re-encode).
  561. if data.startswith(b"\xff\xd8"):
  562. return data
  563. # Not JPEG. Many snapshot endpoints serve PNG/WebP/BMP — transcode to JPEG so
  564. # the browser's MJPEG stream (and JPEG-only downstream) keep working instead of
  565. # dropping the connection (#1902). Run off the event loop: cv2 decode/encode is
  566. # CPU-bound and this can be polled at up to 15 fps while a camera view is open.
  567. transcoded = await asyncio.to_thread(_transcode_to_jpeg, data)
  568. if transcoded is not None:
  569. logger.debug(
  570. "Transcoded non-JPEG snapshot (%d bytes, header %s) to JPEG",
  571. len(data),
  572. data[:4].hex(),
  573. )
  574. return transcoded
  575. # Couldn't decode it as an image at all — most likely not an image response
  576. # (HTML error page, auth redirect, wrong URL). Return the raw bytes as a last
  577. # resort (unchanged behaviour) but log enough to debug.
  578. logger.warning(
  579. "External camera snapshot is not a decodable image "
  580. "(%d bytes, header %s) — verify the camera URL returns an image",
  581. len(data),
  582. data[:4].hex(),
  583. )
  584. return data
  585. async def test_connection(url: str, camera_type: str) -> dict:
  586. """Test camera connection.
  587. Returns:
  588. Dict with {success: bool, error?: str, resolution?: str}
  589. """
  590. logger.info("Testing camera connection: type=%s, url=%s...", camera_type, redact_url_credentials(url)[:50])
  591. try:
  592. frame = await capture_frame(url, camera_type, timeout=10)
  593. logger.info("Capture result: %s bytes", len(frame) if frame else 0)
  594. if frame:
  595. # Try to get resolution from JPEG header
  596. resolution = None
  597. try:
  598. # Simple JPEG dimension extraction
  599. # SOF0 marker is FF C0, followed by length, precision, height, width
  600. sof_markers = [b"\xff\xc0", b"\xff\xc1", b"\xff\xc2"]
  601. for marker in sof_markers:
  602. idx = frame.find(marker)
  603. if idx != -1 and idx + 9 <= len(frame):
  604. height = (frame[idx + 5] << 8) | frame[idx + 6]
  605. width = (frame[idx + 7] << 8) | frame[idx + 8]
  606. resolution = f"{width}x{height}"
  607. break
  608. except (IndexError, ValueError):
  609. pass # Resolution detection is optional; fall back to default
  610. return {"success": True, "resolution": resolution}
  611. else:
  612. return {"success": False, "error": "Failed to capture frame from camera"}
  613. except Exception as e:
  614. # Sanitize error message - don't expose internal details
  615. error_type = type(e).__name__
  616. logger.error("Camera connection test failed: %s", e)
  617. return {"success": False, "error": f"Connection failed: {error_type}"}
  618. async def generate_mjpeg_stream(
  619. url: str,
  620. camera_type: str,
  621. fps: int = 10,
  622. *,
  623. on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
  624. on_frame: Callable[[bytes], None] | None = None,
  625. stop_event: asyncio.Event | None = None,
  626. ) -> AsyncGenerator[bytes, None]:
  627. """Generator yielding MJPEG frames for streaming.
  628. Args:
  629. url: Camera URL or USB device path
  630. camera_type: "mjpeg", "rtsp", "snapshot", or "usb"
  631. fps: Target frames per second
  632. on_process: Called with the spawned ffmpeg process for the ``usb`` and
  633. ``rtsp`` paths so the route layer can register it into the shared
  634. stream registries — that's what lets ``/camera/stop`` and the orphan
  635. janitor find and kill a leaked ffmpeg that's holding a USB device
  636. open (#2675). Without it the process is reachable only from this
  637. generator's own ``finally``, which an abrupt client disconnect can
  638. skip (same cancellation-timing class as #776).
  639. on_frame: Called with each RAW frame, before it is wrapped for the wire,
  640. so the route layer can publish it as the printer's buffered frame
  641. (#2707). It has to be a callback: what this generator yields is
  642. multipart-wrapped, so a consumer of the stream cannot recover the
  643. JPEG, and until now nothing populated the buffer for external
  644. cameras at all — leaving every one-shot consumer (layer timelapse,
  645. finish photo, Obico, plate check) with nothing to reuse and no
  646. option but to open a competing handle on a single-reader device.
  647. Exceptions are logged and swallowed: buffering must never be able
  648. to break the live stream.
  649. stop_event: When set, the reconnect loops stop retrying — so an explicit
  650. stop (which kills the current ffmpeg) doesn't immediately respawn a
  651. new process and reacquire the device.
  652. Yields:
  653. MJPEG frame data with HTTP multipart boundaries
  654. """
  655. frame_interval = 1.0 / max(fps, 1)
  656. last_frame_time = 0.0
  657. def _publish(frame: bytes) -> bytes:
  658. """Hand the raw frame to on_frame, then format it for the wire."""
  659. if on_frame is not None:
  660. try:
  661. on_frame(frame)
  662. except Exception:
  663. logger.exception("on_frame callback raised")
  664. return _format_mjpeg_frame(frame)
  665. if camera_type == "mjpeg":
  666. # Proxy MJPEG stream directly, with reconnect on timeout
  667. max_retries = 3
  668. for attempt in range(max_retries + 1):
  669. frame_yielded = False
  670. async for frame in _stream_mjpeg(url):
  671. frame_yielded = True
  672. current_time = asyncio.get_event_loop().time()
  673. if current_time - last_frame_time >= frame_interval:
  674. last_frame_time = current_time
  675. yield _publish(frame)
  676. if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
  677. break
  678. logger.warning(
  679. "External MJPEG stream ended, reconnecting (attempt %d/%d)...",
  680. attempt + 1,
  681. max_retries,
  682. )
  683. await asyncio.sleep(2)
  684. elif camera_type == "rtsp":
  685. # Use ffmpeg to convert RTSP to MJPEG, with reconnect on timeout
  686. max_retries = 3
  687. for attempt in range(max_retries + 1):
  688. frame_yielded = False
  689. async for frame in _stream_rtsp(url, fps, on_process=on_process):
  690. frame_yielded = True
  691. yield _publish(frame)
  692. if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
  693. break
  694. logger.warning(
  695. "External RTSP stream ended, reconnecting (attempt %d/%d)...",
  696. attempt + 1,
  697. max_retries,
  698. )
  699. await asyncio.sleep(2)
  700. elif camera_type == "usb":
  701. # Use ffmpeg to stream from USB camera
  702. async for frame in _stream_usb(url, fps, on_process=on_process):
  703. yield _publish(frame)
  704. elif camera_type == "snapshot":
  705. # Poll snapshot URL at interval
  706. while True:
  707. try:
  708. frame = await _capture_snapshot(url, timeout=10)
  709. if frame:
  710. yield _publish(frame)
  711. await asyncio.sleep(frame_interval)
  712. except asyncio.CancelledError:
  713. break
  714. except (aiohttp.ClientError, OSError) as e:
  715. logger.warning("Snapshot poll failed: %s", e)
  716. await asyncio.sleep(frame_interval)
  717. def _format_mjpeg_frame(frame: bytes) -> bytes:
  718. """Format frame for MJPEG HTTP response."""
  719. return (
  720. b"--frame\r\n"
  721. b"Content-Type: image/jpeg\r\n"
  722. b"Content-Length: " + str(len(frame)).encode() + b"\r\n"
  723. b"\r\n" + frame + b"\r\n"
  724. )
  725. async def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]:
  726. """Stream frames from MJPEG URL.
  727. Note: This function intentionally makes requests to user-configured URLs.
  728. External camera support requires connecting to user-specified camera endpoints.
  729. URL is sanitized and dangerous destinations are blocked.
  730. """
  731. # Sanitize URL - returns reconstructed URL from validated components
  732. safe_url = _sanitize_camera_url(url, ("http", "https"))
  733. if not safe_url:
  734. logger.error("Invalid MJPEG stream URL: %s...", redact_url_credentials(url)[:50])
  735. return
  736. try:
  737. timeout = aiohttp.ClientTimeout(total=None, sock_read=30)
  738. async with aiohttp.ClientSession(timeout=timeout) as session, session.get(safe_url) as response:
  739. if response.status != 200:
  740. logger.error("MJPEG stream returned status %s", response.status)
  741. return
  742. buffer = b""
  743. jpeg_start = b"\xff\xd8"
  744. jpeg_end = b"\xff\xd9"
  745. async for chunk in response.content.iter_chunked(8192):
  746. buffer += chunk
  747. # Extract complete frames from buffer
  748. while True:
  749. start_idx = buffer.find(jpeg_start)
  750. if start_idx == -1:
  751. buffer = buffer[-2:] if len(buffer) > 2 else buffer
  752. break
  753. if start_idx > 0:
  754. buffer = buffer[start_idx:]
  755. end_idx = buffer.find(jpeg_end, 2)
  756. if end_idx == -1:
  757. break
  758. frame = buffer[: end_idx + 2]
  759. buffer = buffer[end_idx + 2 :]
  760. yield frame
  761. except asyncio.CancelledError:
  762. logger.info("MJPEG stream cancelled")
  763. except (aiohttp.ClientError, OSError) as e:
  764. logger.error("MJPEG stream error: %s", e)
  765. async def _stream_rtsp(
  766. url: str,
  767. fps: int,
  768. *,
  769. on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
  770. ) -> AsyncGenerator[bytes, None]:
  771. """Stream frames from RTSP URL via ffmpeg.
  772. For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead
  773. of relying on ffmpeg's GnuTLS backend, which has compatibility issues
  774. with some printer firmwares.
  775. """
  776. ffmpeg = get_ffmpeg_path()
  777. if not ffmpeg:
  778. logger.error("ffmpeg not found - required for RTSP streaming")
  779. return
  780. from backend.app.services.camera import rtsp_socket_timeout_flag
  781. # If the URL uses rtsps://, set up a TLS proxy so ffmpeg uses plain rtsp://
  782. proxy_server = None
  783. effective_url = url
  784. if url.lower().startswith("rtsps://"):
  785. try:
  786. from urllib.parse import urlparse
  787. from backend.app.services.camera import create_tls_proxy
  788. parsed = urlparse(url)
  789. target_port = parsed.port or 322
  790. proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
  791. # Rewrite URL: rtsps://user:pass@host:port/path → rtsp://user:pass@127.0.0.1:proxy/path
  792. userinfo = ""
  793. if parsed.username:
  794. userinfo = parsed.username
  795. if parsed.password:
  796. userinfo += f":{parsed.password}"
  797. userinfo += "@"
  798. effective_url = f"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}"
  799. if parsed.query:
  800. effective_url += f"?{parsed.query}"
  801. except Exception as e:
  802. logger.warning("Failed to create TLS proxy for RTSP, falling back to direct: %s", e)
  803. effective_url = url
  804. cmd = [
  805. ffmpeg,
  806. "-rtsp_transport",
  807. "tcp",
  808. "-rtsp_flags",
  809. "prefer_tcp",
  810. # Socket I/O timeout name varies by ffmpeg version (#1504); see
  811. # `rtsp_socket_timeout_flag()` in services.camera.
  812. f"-{rtsp_socket_timeout_flag()}",
  813. "30000000",
  814. "-buffer_size",
  815. "1024000",
  816. "-max_delay",
  817. "500000",
  818. "-probesize",
  819. "32",
  820. "-analyzeduration",
  821. "0",
  822. "-fflags",
  823. "nobuffer",
  824. "-flags",
  825. "low_delay",
  826. "-i",
  827. effective_url,
  828. "-f",
  829. "mjpeg",
  830. "-q:v",
  831. "5",
  832. "-r",
  833. str(fps),
  834. "-an",
  835. "-",
  836. ]
  837. process = None
  838. try:
  839. process = await asyncio.create_subprocess_exec(
  840. *cmd,
  841. stdout=asyncio.subprocess.PIPE,
  842. stderr=asyncio.subprocess.PIPE,
  843. )
  844. # Register immediately — before the startup probe below — so a process
  845. # that hangs on connect (rather than exiting) is still reachable by the
  846. # stop endpoint / orphan janitor (#2675).
  847. if on_process is not None:
  848. on_process(process)
  849. # Brief check for immediate startup failures
  850. await asyncio.sleep(0.1)
  851. if process.returncode is not None:
  852. stderr = await process.stderr.read()
  853. # ffmpeg echoes the RTSP input URL, which carries the camera password.
  854. logger.error("ffmpeg RTSP stream failed immediately: %s", redact_url_credentials(stderr.decode())[:300])
  855. return
  856. buffer = b""
  857. jpeg_start = b"\xff\xd8"
  858. jpeg_end = b"\xff\xd9"
  859. while True:
  860. try:
  861. chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=30.0)
  862. if not chunk:
  863. break
  864. buffer += chunk
  865. # Extract complete frames
  866. while True:
  867. start_idx = buffer.find(jpeg_start)
  868. if start_idx == -1:
  869. buffer = buffer[-2:] if len(buffer) > 2 else buffer
  870. break
  871. if start_idx > 0:
  872. buffer = buffer[start_idx:]
  873. end_idx = buffer.find(jpeg_end, 2)
  874. if end_idx == -1:
  875. break
  876. frame = buffer[: end_idx + 2]
  877. buffer = buffer[end_idx + 2 :]
  878. yield frame
  879. except TimeoutError:
  880. logger.warning("RTSP stream read timeout")
  881. break
  882. except asyncio.CancelledError:
  883. logger.info("RTSP stream cancelled")
  884. except OSError as e:
  885. logger.error("RTSP stream error: %s", e)
  886. finally:
  887. if process and process.returncode is None:
  888. process.terminate()
  889. try:
  890. await asyncio.wait_for(process.wait(), timeout=2.0)
  891. except TimeoutError:
  892. process.kill()
  893. await process.wait()
  894. if proxy_server:
  895. proxy_server.close()
  896. await proxy_server.wait_closed()
  897. async def _stream_usb(
  898. device: str,
  899. fps: int,
  900. *,
  901. on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
  902. ) -> AsyncGenerator[bytes, None]:
  903. """Stream frames from USB camera via ffmpeg."""
  904. ffmpeg = get_ffmpeg_path()
  905. if not ffmpeg:
  906. logger.error("ffmpeg not found - required for USB camera streaming")
  907. return
  908. # Validate device path
  909. if not device.startswith("/dev/video"):
  910. logger.error("Invalid USB device path: %s", device)
  911. return
  912. if not Path(device).exists():
  913. logger.error("USB device does not exist: %s", device)
  914. return
  915. # ffmpeg command to stream from USB camera (v4l2)
  916. cmd = [
  917. ffmpeg,
  918. "-f",
  919. "v4l2",
  920. "-framerate",
  921. str(fps),
  922. "-i",
  923. device,
  924. "-f",
  925. "mjpeg",
  926. "-q:v",
  927. "5",
  928. "-r",
  929. str(fps),
  930. "-",
  931. ]
  932. process = None
  933. try:
  934. logger.info("Starting USB camera stream from %s at %s fps", device, fps)
  935. process = await asyncio.create_subprocess_exec(
  936. *cmd,
  937. stdout=asyncio.subprocess.PIPE,
  938. stderr=asyncio.subprocess.PIPE,
  939. )
  940. # Register immediately — before the startup probe below — so a process
  941. # that hangs in open()/ioctl on a still-locked device (rather than
  942. # exiting with a "busy" error) is still reachable by the stop endpoint /
  943. # orphan janitor (#2675).
  944. if on_process is not None:
  945. on_process(process)
  946. # Give ffmpeg a moment to start and check for immediate failures
  947. await asyncio.sleep(0.5)
  948. if process.returncode is not None:
  949. stderr = await process.stderr.read()
  950. logger.error("ffmpeg USB stream failed immediately: %s", stderr.decode()[:300])
  951. return
  952. buffer = b""
  953. jpeg_start = b"\xff\xd8"
  954. jpeg_end = b"\xff\xd9"
  955. while True:
  956. try:
  957. chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=30.0)
  958. if not chunk:
  959. break
  960. buffer += chunk
  961. # Extract complete frames
  962. while True:
  963. start_idx = buffer.find(jpeg_start)
  964. if start_idx == -1:
  965. buffer = buffer[-2:] if len(buffer) > 2 else buffer
  966. break
  967. if start_idx > 0:
  968. buffer = buffer[start_idx:]
  969. end_idx = buffer.find(jpeg_end, 2)
  970. if end_idx == -1:
  971. break
  972. frame = buffer[: end_idx + 2]
  973. buffer = buffer[end_idx + 2 :]
  974. yield frame
  975. except TimeoutError:
  976. logger.warning("USB stream read timeout")
  977. break
  978. except asyncio.CancelledError:
  979. logger.info("USB stream cancelled")
  980. except OSError as e:
  981. logger.error("USB stream error: %s", e)
  982. finally:
  983. if process and process.returncode is None:
  984. process.terminate()
  985. try:
  986. await asyncio.wait_for(process.wait(), timeout=2.0)
  987. except TimeoutError:
  988. process.kill()
  989. await process.wait()