camera.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. """Camera capture service for Bambu Lab printers.
  2. Supports two camera protocols:
  3. - RTSP: Used by X1, X1C, X1E, H2C, H2D, H2DPRO, H2S, P2S (port 322)
  4. - Chamber Image: Used by A1, A1MINI, P1P, P1S (port 6000, custom binary protocol)
  5. """
  6. import asyncio
  7. import logging
  8. import shutil
  9. import ssl
  10. import struct
  11. import uuid
  12. from datetime import datetime
  13. from pathlib import Path
  14. logger = logging.getLogger(__name__)
  15. # JPEG markers
  16. JPEG_START = b"\xff\xd8"
  17. JPEG_END = b"\xff\xd9"
  18. # Cache the ffmpeg path after first lookup
  19. _ffmpeg_path: str | None = None
  20. def get_ffmpeg_path() -> str | None:
  21. """Find the ffmpeg executable path.
  22. Uses shutil.which first, then checks common installation locations
  23. for systems where PATH may be limited (e.g., systemd services).
  24. """
  25. global _ffmpeg_path
  26. if _ffmpeg_path is not None:
  27. return _ffmpeg_path
  28. # Try PATH first
  29. ffmpeg_path = shutil.which("ffmpeg")
  30. # If not found via PATH, check common installation locations
  31. if ffmpeg_path is None:
  32. common_paths = [
  33. "/usr/bin/ffmpeg",
  34. "/usr/local/bin/ffmpeg",
  35. "/opt/homebrew/bin/ffmpeg", # macOS Homebrew
  36. "/snap/bin/ffmpeg", # Ubuntu Snap
  37. "C:\\ffmpeg\\bin\\ffmpeg.exe", # Windows common
  38. ]
  39. for path in common_paths:
  40. if Path(path).exists():
  41. ffmpeg_path = path
  42. break
  43. _ffmpeg_path = ffmpeg_path
  44. if ffmpeg_path:
  45. logger.info(f"Found ffmpeg at: {ffmpeg_path}")
  46. else:
  47. logger.warning("ffmpeg not found in PATH or common locations")
  48. return ffmpeg_path
  49. def supports_rtsp(model: str | None) -> bool:
  50. """Check if printer model supports RTSP camera streaming.
  51. RTSP supported: X1, X1C, X1E, H2C, H2D, H2DPRO, H2S, P2S
  52. Chamber image only: A1, A1MINI, P1P, P1S
  53. """
  54. if model:
  55. model_upper = model.upper()
  56. # These models support RTSP on port 322
  57. if model_upper.startswith(("X1", "H2", "P2")):
  58. return True
  59. # A1/P1 and unknown models use chamber image protocol
  60. return False
  61. def get_camera_port(model: str | None) -> int:
  62. """Get the camera port based on printer model.
  63. X1/H2/P2 series use RTSP on port 322.
  64. A1/P1 series use chamber image protocol on port 6000.
  65. """
  66. if supports_rtsp(model):
  67. return 322
  68. return 6000
  69. def is_chamber_image_model(model: str | None) -> bool:
  70. """Check if printer uses chamber image protocol instead of RTSP.
  71. A1, A1MINI, P1P, P1S use the chamber image protocol on port 6000.
  72. """
  73. return not supports_rtsp(model)
  74. def build_camera_url(ip_address: str, access_code: str, model: str | None) -> str:
  75. """Build the RTSPS URL for the printer camera (RTSP models only)."""
  76. port = get_camera_port(model)
  77. return f"rtsps://bblp:{access_code}@{ip_address}:{port}/streaming/live/1"
  78. def _create_chamber_auth_payload(access_code: str) -> bytes:
  79. """Create the 80-byte authentication payload for chamber image protocol.
  80. Format:
  81. - Bytes 0-3: 0x40 0x00 0x00 0x00 (magic)
  82. - Bytes 4-7: 0x00 0x30 0x00 0x00 (command)
  83. - Bytes 8-15: zeros (padding)
  84. - Bytes 16-47: username "bblp" (32 bytes, null-padded)
  85. - Bytes 48-79: access code (32 bytes, null-padded)
  86. """
  87. username = b"bblp"
  88. access_code_bytes = access_code.encode("utf-8")
  89. # Build the 80-byte payload
  90. payload = struct.pack(
  91. "<II8s32s32s",
  92. 0x40, # Magic header
  93. 0x3000, # Command
  94. b"\x00" * 8, # Padding
  95. username.ljust(32, b"\x00"), # Username padded to 32 bytes
  96. access_code_bytes.ljust(32, b"\x00"), # Access code padded to 32 bytes
  97. )
  98. return payload
  99. def _create_ssl_context() -> ssl.SSLContext:
  100. """Create an SSL context for chamber image connection.
  101. Bambu printers use self-signed certificates, so we disable verification.
  102. """
  103. ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
  104. ctx.check_hostname = False
  105. ctx.verify_mode = ssl.CERT_NONE
  106. return ctx
  107. async def read_chamber_image_frame(
  108. ip_address: str,
  109. access_code: str,
  110. timeout: float = 10.0,
  111. ) -> bytes | None:
  112. """Read a single JPEG frame from the chamber image protocol.
  113. This is used by A1/P1 printers which don't support RTSP.
  114. Args:
  115. ip_address: Printer IP address
  116. access_code: Printer access code
  117. timeout: Connection timeout in seconds
  118. Returns:
  119. JPEG image data or None if failed
  120. """
  121. port = 6000
  122. ssl_context = _create_ssl_context()
  123. try:
  124. # Connect with SSL
  125. reader, writer = await asyncio.wait_for(
  126. asyncio.open_connection(ip_address, port, ssl=ssl_context),
  127. timeout=timeout,
  128. )
  129. try:
  130. # Send authentication payload
  131. auth_payload = _create_chamber_auth_payload(access_code)
  132. writer.write(auth_payload)
  133. await writer.drain()
  134. # Read the 16-byte header
  135. header = await asyncio.wait_for(reader.readexactly(16), timeout=timeout)
  136. if len(header) < 16:
  137. logger.error("Chamber image: incomplete header received")
  138. return None
  139. # Parse payload size from header (little-endian uint32 at offset 0)
  140. payload_size = struct.unpack("<I", header[0:4])[0]
  141. if payload_size == 0 or payload_size > 10_000_000: # Sanity check: max 10MB
  142. logger.error(f"Chamber image: invalid payload size {payload_size}")
  143. return None
  144. # Read the JPEG data
  145. jpeg_data = await asyncio.wait_for(
  146. reader.readexactly(payload_size),
  147. timeout=timeout,
  148. )
  149. # Validate JPEG markers
  150. if not jpeg_data.startswith(JPEG_START):
  151. logger.error("Chamber image: data is not a valid JPEG (missing start marker)")
  152. return None
  153. if not jpeg_data.endswith(JPEG_END):
  154. logger.warning("Chamber image: JPEG missing end marker, may be truncated")
  155. logger.debug(f"Chamber image: received {len(jpeg_data)} bytes")
  156. return jpeg_data
  157. finally:
  158. writer.close()
  159. try:
  160. await writer.wait_closed()
  161. except Exception:
  162. pass
  163. except TimeoutError:
  164. logger.error(f"Chamber image: connection timeout to {ip_address}:{port}")
  165. return None
  166. except ConnectionRefusedError:
  167. logger.error(f"Chamber image: connection refused by {ip_address}:{port}")
  168. return None
  169. except Exception as e:
  170. logger.exception(f"Chamber image: error connecting to {ip_address}:{port}: {e}")
  171. return None
  172. async def generate_chamber_image_stream(
  173. ip_address: str,
  174. access_code: str,
  175. fps: int = 5,
  176. ) -> asyncio.StreamReader | None:
  177. """Create a persistent connection for streaming chamber images.
  178. Returns a connected reader or None if connection failed.
  179. """
  180. port = 6000
  181. ssl_context = _create_ssl_context()
  182. try:
  183. reader, writer = await asyncio.wait_for(
  184. asyncio.open_connection(ip_address, port, ssl=ssl_context),
  185. timeout=10.0,
  186. )
  187. # Send authentication payload
  188. auth_payload = _create_chamber_auth_payload(access_code)
  189. writer.write(auth_payload)
  190. await writer.drain()
  191. logger.info(f"Chamber image: connected to {ip_address}:{port}")
  192. return reader, writer
  193. except Exception as e:
  194. logger.error(f"Chamber image: failed to connect to {ip_address}:{port}: {e}")
  195. return None
  196. async def read_next_chamber_frame(reader: asyncio.StreamReader, timeout: float = 10.0) -> bytes | None:
  197. """Read the next JPEG frame from an established chamber image connection."""
  198. try:
  199. # Read the 16-byte header
  200. header = await asyncio.wait_for(reader.readexactly(16), timeout=timeout)
  201. # Parse payload size from header (little-endian uint32 at offset 0)
  202. payload_size = struct.unpack("<I", header[0:4])[0]
  203. if payload_size == 0 or payload_size > 10_000_000:
  204. logger.error(f"Chamber image: invalid payload size {payload_size}")
  205. return None
  206. # Read the JPEG data
  207. jpeg_data = await asyncio.wait_for(
  208. reader.readexactly(payload_size),
  209. timeout=timeout,
  210. )
  211. return jpeg_data
  212. except asyncio.IncompleteReadError:
  213. logger.warning("Chamber image: connection closed by printer")
  214. return None
  215. except TimeoutError:
  216. logger.warning("Chamber image: read timeout")
  217. return None
  218. except Exception as e:
  219. logger.error(f"Chamber image: error reading frame: {e}")
  220. return None
  221. async def capture_camera_frame(
  222. ip_address: str,
  223. access_code: str,
  224. model: str | None,
  225. output_path: Path,
  226. timeout: int = 30,
  227. ) -> bool:
  228. """Capture a single frame from the printer's camera stream.
  229. Uses the appropriate protocol based on printer model:
  230. - A1/P1: Chamber image protocol (port 6000)
  231. - X1/H2/P2: RTSP via ffmpeg (port 322)
  232. Args:
  233. ip_address: Printer IP address
  234. access_code: Printer access code
  235. model: Printer model (X1, H2D, P1, A1, etc.)
  236. output_path: Path where to save the captured image
  237. timeout: Timeout in seconds for the capture operation
  238. Returns:
  239. True if capture was successful, False otherwise
  240. """
  241. # Ensure output directory exists
  242. output_path.parent.mkdir(parents=True, exist_ok=True)
  243. # Use chamber image protocol for A1/P1 models
  244. if is_chamber_image_model(model):
  245. logger.info(f"Capturing camera frame from {ip_address} using chamber image protocol (model: {model})")
  246. jpeg_data = await read_chamber_image_frame(ip_address, access_code, timeout=float(timeout))
  247. if jpeg_data:
  248. try:
  249. with open(output_path, "wb") as f:
  250. f.write(jpeg_data)
  251. logger.info(f"Successfully captured camera frame: {output_path}")
  252. return True
  253. except Exception as e:
  254. logger.error(f"Failed to write camera frame: {e}")
  255. return False
  256. return False
  257. # Use RTSP/ffmpeg for X1/H2/P2 models
  258. camera_url = build_camera_url(ip_address, access_code, model)
  259. ffmpeg = get_ffmpeg_path()
  260. if not ffmpeg:
  261. logger.error("ffmpeg not found. Please install ffmpeg to enable camera capture.")
  262. return False
  263. # ffmpeg command to capture a single frame from RTSPS stream
  264. cmd = [
  265. ffmpeg,
  266. "-y", # Overwrite output
  267. "-rtsp_transport",
  268. "tcp",
  269. "-rtsp_flags",
  270. "prefer_tcp",
  271. "-i",
  272. camera_url,
  273. "-frames:v",
  274. "1",
  275. "-update",
  276. "1",
  277. "-q:v",
  278. "2",
  279. str(output_path),
  280. ]
  281. logger.info(f"Capturing camera frame from {ip_address} using RTSP (model: {model})")
  282. try:
  283. process = await asyncio.create_subprocess_exec(
  284. *cmd,
  285. stdout=asyncio.subprocess.PIPE,
  286. stderr=asyncio.subprocess.PIPE,
  287. )
  288. try:
  289. stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
  290. except TimeoutError:
  291. process.kill()
  292. await process.wait()
  293. logger.error(f"Camera capture timed out after {timeout}s")
  294. return False
  295. if process.returncode != 0:
  296. stderr_text = stderr.decode() if stderr else "Unknown error"
  297. logger.error(f"ffmpeg failed with code {process.returncode}: {stderr_text}")
  298. return False
  299. if output_path.exists() and output_path.stat().st_size > 0:
  300. logger.info(f"Successfully captured camera frame: {output_path}")
  301. return True
  302. else:
  303. logger.error("Camera capture produced no output file")
  304. return False
  305. except FileNotFoundError:
  306. logger.error("ffmpeg not found. Please install ffmpeg to enable camera capture.")
  307. return False
  308. except Exception as e:
  309. logger.exception(f"Camera capture failed: {e}")
  310. return False
  311. async def capture_finish_photo(
  312. printer_id: int,
  313. ip_address: str,
  314. access_code: str,
  315. model: str | None,
  316. archive_dir: Path,
  317. ) -> str | None:
  318. """Capture a finish photo and save it to the archive's photos folder.
  319. Args:
  320. printer_id: ID of the printer
  321. ip_address: Printer IP address
  322. access_code: Printer access code
  323. model: Printer model
  324. archive_dir: Directory of the archive (where the 3MF is stored)
  325. Returns:
  326. Filename of the captured photo, or None if capture failed
  327. """
  328. # Create photos subdirectory
  329. photos_dir = archive_dir / "photos"
  330. photos_dir.mkdir(parents=True, exist_ok=True)
  331. # Generate filename with timestamp
  332. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  333. filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
  334. output_path = photos_dir / filename
  335. success = await capture_camera_frame(
  336. ip_address=ip_address,
  337. access_code=access_code,
  338. model=model,
  339. output_path=output_path,
  340. timeout=30,
  341. )
  342. if success:
  343. logger.info(f"Finish photo saved: {filename}")
  344. return filename
  345. else:
  346. logger.warning(f"Failed to capture finish photo for printer {printer_id}")
  347. return None
  348. async def test_camera_connection(
  349. ip_address: str,
  350. access_code: str,
  351. model: str | None,
  352. ) -> dict:
  353. """Test if the camera stream is accessible.
  354. Returns dict with success status and any error message.
  355. """
  356. import tempfile
  357. with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
  358. test_path = Path(f.name)
  359. try:
  360. success = await capture_camera_frame(
  361. ip_address=ip_address,
  362. access_code=access_code,
  363. model=model,
  364. output_path=test_path,
  365. timeout=15,
  366. )
  367. if success:
  368. return {"success": True, "message": "Camera connection successful"}
  369. else:
  370. return {
  371. "success": False,
  372. "error": (
  373. "Failed to capture frame from camera. "
  374. "Ensure the printer is powered on, camera is enabled, and Developer Mode is active. "
  375. "If running in Docker, try 'network_mode: host' in docker-compose.yml."
  376. ),
  377. }
  378. finally:
  379. # Clean up test file
  380. if test_path.exists():
  381. test_path.unlink()