camera.py 7.3 KB

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