camera.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. """Camera capture service for Bambu Lab printers.
  2. Captures images from the printer's RTSPS camera stream using ffmpeg.
  3. """
  4. import asyncio
  5. import logging
  6. import shutil
  7. from pathlib import Path
  8. from datetime import datetime
  9. import uuid
  10. from backend.app.core.config import settings
  11. logger = logging.getLogger(__name__)
  12. # Cache the ffmpeg path after first lookup
  13. _ffmpeg_path: str | None = None
  14. def get_ffmpeg_path() -> str | None:
  15. """Find the ffmpeg executable path.
  16. Uses shutil.which first, then checks common installation locations
  17. for systems where PATH may be limited (e.g., systemd services).
  18. """
  19. global _ffmpeg_path
  20. if _ffmpeg_path is not None:
  21. return _ffmpeg_path
  22. # Try PATH first
  23. ffmpeg_path = shutil.which("ffmpeg")
  24. # If not found via PATH, check common installation locations
  25. if ffmpeg_path is None:
  26. common_paths = [
  27. "/usr/bin/ffmpeg",
  28. "/usr/local/bin/ffmpeg",
  29. "/opt/homebrew/bin/ffmpeg", # macOS Homebrew
  30. "/snap/bin/ffmpeg", # Ubuntu Snap
  31. "C:\\ffmpeg\\bin\\ffmpeg.exe", # Windows common
  32. ]
  33. for path in common_paths:
  34. if Path(path).exists():
  35. ffmpeg_path = path
  36. break
  37. _ffmpeg_path = ffmpeg_path
  38. if ffmpeg_path:
  39. logger.info(f"Found ffmpeg at: {ffmpeg_path}")
  40. else:
  41. logger.warning("ffmpeg not found in PATH or common locations")
  42. return ffmpeg_path
  43. def get_camera_port(model: str | None) -> int:
  44. """Get the RTSPS port based on printer model.
  45. X1 and H2D series use port 322.
  46. P1 and A1 series use port 6000.
  47. """
  48. if model:
  49. model_upper = model.upper()
  50. if model_upper.startswith(("X1", "H2")):
  51. return 322
  52. # Default to 6000 for P1/A1 or unknown models
  53. return 6000
  54. def build_camera_url(ip_address: str, access_code: str, model: str | None) -> str:
  55. """Build the RTSPS URL for the printer camera."""
  56. port = get_camera_port(model)
  57. return f"rtsps://bblp:{access_code}@{ip_address}:{port}/streaming/live/1"
  58. async def capture_camera_frame(
  59. ip_address: str,
  60. access_code: str,
  61. model: str | None,
  62. output_path: Path,
  63. timeout: int = 30,
  64. ) -> bool:
  65. """Capture a single frame from the printer's camera stream.
  66. Args:
  67. ip_address: Printer IP address
  68. access_code: Printer access code
  69. model: Printer model (X1, H2D, P1, A1, etc.)
  70. output_path: Path where to save the captured image
  71. timeout: Timeout in seconds for the capture operation
  72. Returns:
  73. True if capture was successful, False otherwise
  74. """
  75. camera_url = build_camera_url(ip_address, access_code, model)
  76. # Ensure output directory exists
  77. output_path.parent.mkdir(parents=True, exist_ok=True)
  78. ffmpeg = get_ffmpeg_path()
  79. if not ffmpeg:
  80. logger.error("ffmpeg not found. Please install ffmpeg to enable camera capture.")
  81. return False
  82. # ffmpeg command to capture a single frame from RTSPS stream
  83. # -rtsp_transport tcp: Use TCP for RTSP (more reliable)
  84. # -rtsp_flags prefer_tcp: Prefer TCP for RTSP
  85. # -y: Overwrite output file
  86. # -frames:v 1: Capture only 1 frame
  87. # -update 1: Allow writing single image without sequence pattern
  88. # -q:v 2: High quality JPEG (1-31, lower is better)
  89. cmd = [
  90. ffmpeg,
  91. "-y", # Overwrite output
  92. "-rtsp_transport", "tcp",
  93. "-rtsp_flags", "prefer_tcp",
  94. "-i", camera_url,
  95. "-frames:v", "1",
  96. "-update", "1",
  97. "-q:v", "2",
  98. str(output_path),
  99. ]
  100. logger.info(f"Capturing camera frame from {ip_address} (model: {model})")
  101. try:
  102. # Run ffmpeg asynchronously with timeout
  103. process = await asyncio.create_subprocess_exec(
  104. *cmd,
  105. stdout=asyncio.subprocess.PIPE,
  106. stderr=asyncio.subprocess.PIPE,
  107. )
  108. try:
  109. stdout, stderr = await asyncio.wait_for(
  110. process.communicate(),
  111. timeout=timeout
  112. )
  113. except asyncio.TimeoutError:
  114. process.kill()
  115. await process.wait()
  116. logger.error(f"Camera capture timed out after {timeout}s")
  117. return False
  118. if process.returncode != 0:
  119. stderr_text = stderr.decode() if stderr else "Unknown error"
  120. logger.error(f"ffmpeg failed with code {process.returncode}: {stderr_text}")
  121. return False
  122. if output_path.exists() and output_path.stat().st_size > 0:
  123. logger.info(f"Successfully captured camera frame: {output_path}")
  124. return True
  125. else:
  126. logger.error("Camera capture produced no output file")
  127. return False
  128. except FileNotFoundError:
  129. logger.error("ffmpeg not found. Please install ffmpeg to enable camera capture.")
  130. return False
  131. except Exception as e:
  132. logger.exception(f"Camera capture failed: {e}")
  133. return False
  134. async def capture_finish_photo(
  135. printer_id: int,
  136. ip_address: str,
  137. access_code: str,
  138. model: str | None,
  139. archive_dir: Path,
  140. ) -> str | None:
  141. """Capture a finish photo and save it to the archive's photos folder.
  142. Args:
  143. printer_id: ID of the printer
  144. ip_address: Printer IP address
  145. access_code: Printer access code
  146. model: Printer model
  147. archive_dir: Directory of the archive (where the 3MF is stored)
  148. Returns:
  149. Filename of the captured photo, or None if capture failed
  150. """
  151. # Create photos subdirectory
  152. photos_dir = archive_dir / "photos"
  153. photos_dir.mkdir(parents=True, exist_ok=True)
  154. # Generate filename with timestamp
  155. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  156. filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
  157. output_path = photos_dir / filename
  158. success = await capture_camera_frame(
  159. ip_address=ip_address,
  160. access_code=access_code,
  161. model=model,
  162. output_path=output_path,
  163. timeout=30,
  164. )
  165. if success:
  166. logger.info(f"Finish photo saved: {filename}")
  167. return filename
  168. else:
  169. logger.warning(f"Failed to capture finish photo for printer {printer_id}")
  170. return None
  171. async def test_camera_connection(
  172. ip_address: str,
  173. access_code: str,
  174. model: str | None,
  175. ) -> dict:
  176. """Test if the camera stream is accessible.
  177. Returns dict with success status and any error message.
  178. """
  179. import tempfile
  180. with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
  181. test_path = Path(f.name)
  182. try:
  183. success = await capture_camera_frame(
  184. ip_address=ip_address,
  185. access_code=access_code,
  186. model=model,
  187. output_path=test_path,
  188. timeout=15,
  189. )
  190. if success:
  191. return {"success": True, "message": "Camera connection successful"}
  192. else:
  193. return {"success": False, "error": "Failed to capture frame from camera"}
  194. finally:
  195. # Clean up test file
  196. if test_path.exists():
  197. test_path.unlink()