camera.py 15 KB

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