camera.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644
  1. """Camera capture service for Bambu Lab printers.
  2. Supports two camera protocols:
  3. - RTSP: Used by X1, X1C, X1E, X2D, 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 os
  9. import shutil
  10. import ssl
  11. import struct
  12. import uuid
  13. from datetime import datetime
  14. from pathlib import Path
  15. logger = logging.getLogger(__name__)
  16. # JPEG markers
  17. JPEG_START = b"\xff\xd8"
  18. JPEG_END = b"\xff\xd9"
  19. # Cache the ffmpeg path after first lookup
  20. _ffmpeg_path: str | None = None
  21. # Track PIDs of ffmpeg processes spawned for one-shot frame capture (snapshot).
  22. # The cleanup task in routes/camera.py checks this set to avoid killing active captures.
  23. _active_capture_pids: set[int] = set()
  24. def get_ffmpeg_path() -> str | None:
  25. """Find the ffmpeg executable path.
  26. Uses shutil.which first, then checks common installation locations
  27. for systems where PATH may be limited (e.g., systemd services).
  28. """
  29. global _ffmpeg_path
  30. if _ffmpeg_path is not None:
  31. return _ffmpeg_path
  32. # Try PATH first
  33. ffmpeg_path = shutil.which("ffmpeg")
  34. # If not found via PATH, check common installation locations
  35. if ffmpeg_path is None:
  36. common_paths = [
  37. "/usr/bin/ffmpeg",
  38. "/usr/local/bin/ffmpeg",
  39. "/opt/homebrew/bin/ffmpeg", # macOS Homebrew
  40. "/snap/bin/ffmpeg", # Ubuntu Snap
  41. "C:\\ffmpeg\\bin\\ffmpeg.exe", # Windows common
  42. ]
  43. for path in common_paths:
  44. if Path(path).exists():
  45. ffmpeg_path = path
  46. break
  47. _ffmpeg_path = ffmpeg_path
  48. if ffmpeg_path:
  49. logger.info("Found ffmpeg at: %s", ffmpeg_path)
  50. else:
  51. logger.warning("ffmpeg not found in PATH or common locations")
  52. return ffmpeg_path
  53. def supports_rtsp(model: str | None) -> bool:
  54. """Check if printer model supports RTSP camera streaming.
  55. RTSP supported: X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, P2S
  56. Chamber image only: A1, A1MINI, P1P, P1S
  57. Note: Model can be either display name (e.g., "P2S") or internal code (e.g., "N7").
  58. Internal codes from MQTT/SSDP:
  59. - BL-P001: X1/X1C
  60. - C13: X1E
  61. - N6: X2D
  62. - O1D: H2D
  63. - O1C, O1C2: H2C
  64. - O1S: H2S
  65. - O1E, O2D: H2D Pro
  66. - N7: P2S
  67. """
  68. if model:
  69. model_upper = model.upper()
  70. # Display names: X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, P2S
  71. if model_upper.startswith(("X1", "X2", "H2", "P2")):
  72. return True
  73. # Internal codes for RTSP models
  74. if model_upper in ("BL-P001", "C13", "N6", "O1D", "O1C", "O1C2", "O1S", "O1E", "O2D", "N7"):
  75. return True
  76. # A1/P1 and unknown models use chamber image protocol
  77. return False
  78. def get_camera_port(model: str | None) -> int:
  79. """Get the camera port based on printer model.
  80. X1/X2/H2/P2 series use RTSP on port 322.
  81. A1/P1 series use chamber image protocol on port 6000.
  82. """
  83. if supports_rtsp(model):
  84. return 322
  85. return 6000
  86. def rewrite_rtsp_request_url(data: bytes, proxy_url: bytes, real_url: bytes) -> bytes:
  87. """Rewrite RTSP request-line URLs, leaving other lines (e.g. Authorization) intact.
  88. RTSP request lines have the form ``METHOD <url> RTSP/1.0\\r\\n``.
  89. Only those lines are modified so that Digest auth headers (which embed
  90. the original URL and a cryptographic hash) are not broken.
  91. """
  92. rtsp_marker = b" RTSP/1.0"
  93. if rtsp_marker not in data:
  94. return data
  95. lines = data.split(b"\r\n")
  96. for i, line in enumerate(lines):
  97. if line.endswith(rtsp_marker):
  98. lines[i] = line.replace(proxy_url, real_url)
  99. break
  100. return b"\r\n".join(lines)
  101. async def create_tls_proxy(target_host: str, target_port: int) -> tuple[int, "asyncio.Server"]:
  102. """Create a local TCP→TLS proxy for RTSP streams.
  103. Bambu printers use RTSPS (RTSP over TLS) with self-signed certificates.
  104. The Debian ffmpeg package uses GnuTLS, whose hardened defaults reject
  105. certain TLS behaviors (renegotiation, legacy ciphers) that some printer
  106. firmwares (notably P2S) rely on. This causes streams to drop after a
  107. few seconds.
  108. This proxy terminates TLS using Python's ssl module (OpenSSL), which is
  109. more permissive, and exposes a plain TCP port that ffmpeg connects to
  110. with ``rtsp://`` instead of ``rtsps://``.
  111. RTSP embeds URLs in protocol messages (DESCRIBE, SETUP, PLAY). The proxy
  112. rewrites ``127.0.0.1:<proxy_port>`` → ``<target_host>:<target_port>`` in
  113. client→server data so the printer recognises the stream path.
  114. Returns ``(local_port, server)``. Caller must close the server when done.
  115. """
  116. ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
  117. ssl_ctx.check_hostname = False
  118. ssl_ctx.verify_mode = ssl.CERT_NONE
  119. # Filled in after the server socket is created (handler only runs after).
  120. _local_port: list[int] = [0]
  121. async def _handle(client_reader: asyncio.StreamReader, client_writer: asyncio.StreamWriter):
  122. tls_writer = None
  123. try:
  124. tls_reader, tls_writer = await asyncio.wait_for(
  125. asyncio.open_connection(target_host, target_port, ssl=ssl_ctx),
  126. timeout=10.0,
  127. )
  128. # URL patterns for RTSP request-line rewriting.
  129. proxy_url = f"rtsp://127.0.0.1:{_local_port[0]}".encode()
  130. real_url = f"rtsps://{target_host}:{target_port}".encode()
  131. # Note on the broad except below: dst.write() raises RuntimeError
  132. # under uvloop when the underlying handle has already been torn
  133. # down (uvloop.loop.UVHandle._ensure_alive). asyncio's default
  134. # selector loop reports the same situation as ConnectionResetError
  135. # / OSError, so a tuple that doesn't include RuntimeError leaks the
  136. # uvloop variant up to asyncio's unhandled-exception logger
  137. # ("Unhandled exception in client_connected_cb"). The forwarders
  138. # are intentionally fire-and-forget on tear-down — once either
  139. # peer drops, both halves of the proxy should exit quietly.
  140. async def _fwd_to_server(src: asyncio.StreamReader, dst: asyncio.StreamWriter):
  141. """Forward client→server, rewriting RTSP request-line URLs only."""
  142. try:
  143. while True:
  144. data = await src.read(65536)
  145. if not data:
  146. break
  147. data = rewrite_rtsp_request_url(data, proxy_url, real_url)
  148. dst.write(data)
  149. await dst.drain()
  150. except (ConnectionError, OSError, asyncio.CancelledError, RuntimeError):
  151. pass
  152. finally:
  153. if not dst.is_closing():
  154. try:
  155. dst.close()
  156. except OSError:
  157. pass
  158. async def _fwd_to_client(src: asyncio.StreamReader, dst: asyncio.StreamWriter):
  159. """Forward server→client unchanged."""
  160. try:
  161. while True:
  162. data = await src.read(65536)
  163. if not data:
  164. break
  165. dst.write(data)
  166. await dst.drain()
  167. except (ConnectionError, OSError, asyncio.CancelledError, RuntimeError):
  168. pass
  169. finally:
  170. if not dst.is_closing():
  171. try:
  172. dst.close()
  173. except OSError:
  174. pass
  175. await asyncio.gather(
  176. _fwd_to_server(client_reader, tls_writer),
  177. _fwd_to_client(tls_reader, client_writer),
  178. )
  179. except (ConnectionError, OSError, TimeoutError) as e:
  180. logger.debug("TLS proxy connection to %s:%s failed: %s", target_host, target_port, e)
  181. finally:
  182. for w in (client_writer, tls_writer):
  183. if w and not w.is_closing():
  184. try:
  185. w.close()
  186. except OSError:
  187. pass
  188. server = await asyncio.start_server(_handle, "127.0.0.1", 0)
  189. _local_port[0] = server.sockets[0].getsockname()[1]
  190. logger.debug("TLS proxy for %s:%s listening on 127.0.0.1:%s", target_host, target_port, _local_port[0])
  191. return _local_port[0], server
  192. def is_chamber_image_model(model: str | None) -> bool:
  193. """Check if printer uses chamber image protocol instead of RTSP.
  194. A1, A1MINI, P1P, P1S use the chamber image protocol on port 6000.
  195. """
  196. return not supports_rtsp(model)
  197. def build_camera_url(ip_address: str, access_code: str, model: str | None) -> str:
  198. """Build the RTSPS URL for the printer camera (RTSP models only)."""
  199. port = get_camera_port(model)
  200. return f"rtsps://bblp:{access_code}@{ip_address}:{port}/streaming/live/1"
  201. def _create_chamber_auth_payload(access_code: str) -> bytes:
  202. """Create the 80-byte authentication payload for chamber image protocol.
  203. Format:
  204. - Bytes 0-3: 0x40 0x00 0x00 0x00 (magic)
  205. - Bytes 4-7: 0x00 0x30 0x00 0x00 (command)
  206. - Bytes 8-15: zeros (padding)
  207. - Bytes 16-47: username "bblp" (32 bytes, null-padded)
  208. - Bytes 48-79: access code (32 bytes, null-padded)
  209. """
  210. username = b"bblp"
  211. access_code_bytes = access_code.encode("utf-8")
  212. # Build the 80-byte payload
  213. payload = struct.pack(
  214. "<II8s32s32s",
  215. 0x40, # Magic header
  216. 0x3000, # Command
  217. b"\x00" * 8, # Padding
  218. username.ljust(32, b"\x00"), # Username padded to 32 bytes
  219. access_code_bytes.ljust(32, b"\x00"), # Access code padded to 32 bytes
  220. )
  221. return payload
  222. def _create_ssl_context() -> ssl.SSLContext:
  223. """Create an SSL context for chamber image connection.
  224. Bambu printers use self-signed certificates, so we disable verification.
  225. """
  226. ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
  227. ctx.check_hostname = False
  228. ctx.verify_mode = ssl.CERT_NONE
  229. return ctx
  230. async def read_chamber_image_frame(
  231. ip_address: str,
  232. access_code: str,
  233. timeout: float = 10.0,
  234. ) -> bytes | None:
  235. """Read a single JPEG frame from the chamber image protocol.
  236. This is used by A1/P1 printers which don't support RTSP.
  237. Args:
  238. ip_address: Printer IP address
  239. access_code: Printer access code
  240. timeout: Connection timeout in seconds
  241. Returns:
  242. JPEG image data or None if failed
  243. """
  244. port = 6000
  245. ssl_context = _create_ssl_context()
  246. try:
  247. # Connect with SSL
  248. reader, writer = await asyncio.wait_for(
  249. asyncio.open_connection(ip_address, port, ssl=ssl_context),
  250. timeout=timeout,
  251. )
  252. try:
  253. # Send authentication payload
  254. auth_payload = _create_chamber_auth_payload(access_code)
  255. writer.write(auth_payload)
  256. await writer.drain()
  257. # Read the 16-byte header
  258. header = await asyncio.wait_for(reader.readexactly(16), timeout=timeout)
  259. if len(header) < 16:
  260. logger.error("Chamber image: incomplete header received")
  261. return None
  262. # Parse payload size from header (little-endian uint32 at offset 0)
  263. payload_size = struct.unpack("<I", header[0:4])[0]
  264. if payload_size == 0 or payload_size > 10_000_000: # Sanity check: max 10MB
  265. logger.error("Chamber image: invalid payload size %s", payload_size)
  266. return None
  267. # Read the JPEG data
  268. jpeg_data = await asyncio.wait_for(
  269. reader.readexactly(payload_size),
  270. timeout=timeout,
  271. )
  272. # Validate JPEG markers
  273. if not jpeg_data.startswith(JPEG_START):
  274. logger.error("Chamber image: data is not a valid JPEG (missing start marker)")
  275. return None
  276. if not jpeg_data.endswith(JPEG_END):
  277. logger.warning("Chamber image: JPEG missing end marker, may be truncated")
  278. logger.debug("Chamber image: received %s bytes", len(jpeg_data))
  279. return jpeg_data
  280. finally:
  281. writer.close()
  282. try:
  283. await writer.wait_closed()
  284. except OSError:
  285. pass # Socket already closed; cleanup is best-effort
  286. except TimeoutError:
  287. logger.error("Chamber image: connection timeout to %s:%s", ip_address, port)
  288. return None
  289. except ConnectionRefusedError:
  290. logger.error("Chamber image: connection refused by %s:%s", ip_address, port)
  291. return None
  292. except Exception as e:
  293. logger.exception("Chamber image: error connecting to %s:%s: %s", ip_address, port, e)
  294. return None
  295. async def generate_chamber_image_stream(
  296. ip_address: str,
  297. access_code: str,
  298. fps: int = 5,
  299. ) -> asyncio.StreamReader | None:
  300. """Create a persistent connection for streaming chamber images.
  301. Returns a connected reader or None if connection failed.
  302. """
  303. port = 6000
  304. ssl_context = _create_ssl_context()
  305. try:
  306. reader, writer = await asyncio.wait_for(
  307. asyncio.open_connection(ip_address, port, ssl=ssl_context),
  308. timeout=10.0,
  309. )
  310. # Send authentication payload
  311. auth_payload = _create_chamber_auth_payload(access_code)
  312. writer.write(auth_payload)
  313. await writer.drain()
  314. logger.info("Chamber image: connected to %s:%s", ip_address, port)
  315. return reader, writer
  316. except Exception as e:
  317. logger.error("Chamber image: failed to connect to %s:%s: %s", ip_address, port, e)
  318. return None
  319. async def read_next_chamber_frame(reader: asyncio.StreamReader, timeout: float = 10.0) -> bytes | None:
  320. """Read the next JPEG frame from an established chamber image connection."""
  321. try:
  322. # Read the 16-byte header
  323. header = await asyncio.wait_for(reader.readexactly(16), timeout=timeout)
  324. # Parse payload size from header (little-endian uint32 at offset 0)
  325. payload_size = struct.unpack("<I", header[0:4])[0]
  326. if payload_size == 0 or payload_size > 10_000_000:
  327. logger.error("Chamber image: invalid payload size %s", payload_size)
  328. return None
  329. # Read the JPEG data
  330. jpeg_data = await asyncio.wait_for(
  331. reader.readexactly(payload_size),
  332. timeout=timeout,
  333. )
  334. return jpeg_data
  335. except asyncio.IncompleteReadError:
  336. logger.warning("Chamber image: connection closed by printer")
  337. return None
  338. except TimeoutError:
  339. logger.warning("Chamber image: read timeout")
  340. return None
  341. except Exception as e:
  342. logger.error("Chamber image: error reading frame: %s", e)
  343. return None
  344. async def capture_camera_frame(
  345. ip_address: str,
  346. access_code: str,
  347. model: str | None,
  348. output_path: Path,
  349. timeout: int = 30,
  350. ) -> bool:
  351. """Capture a single frame from the printer's camera stream and save to disk.
  352. Uses capture_camera_frame_bytes() internally for protocol selection,
  353. then writes the result to the specified output path.
  354. Args:
  355. ip_address: Printer IP address
  356. access_code: Printer access code
  357. model: Printer model (X1, H2D, P1, A1, etc.)
  358. output_path: Path where to save the captured image
  359. timeout: Timeout in seconds for the capture operation
  360. Returns:
  361. True if capture was successful, False otherwise
  362. """
  363. output_path.parent.mkdir(parents=True, exist_ok=True)
  364. jpeg_data = await capture_camera_frame_bytes(ip_address, access_code, model, timeout)
  365. if jpeg_data:
  366. try:
  367. with open(output_path, "wb") as f:
  368. f.write(jpeg_data)
  369. logger.info("Saved camera frame to: %s", output_path)
  370. return True
  371. except OSError as e:
  372. logger.error("Failed to write camera frame: %s", e)
  373. return False
  374. return False
  375. async def capture_camera_frame_bytes(
  376. ip_address: str,
  377. access_code: str,
  378. model: str | None,
  379. timeout: int = 15,
  380. ) -> bytes | None:
  381. """Capture a single frame and return as JPEG bytes (no disk write).
  382. Uses the same protocol selection as capture_camera_frame but returns
  383. bytes directly instead of writing to disk.
  384. Args:
  385. ip_address: Printer IP address
  386. access_code: Printer access code
  387. model: Printer model (X1, H2D, P1, A1, etc.)
  388. timeout: Timeout in seconds for the capture operation
  389. Returns:
  390. JPEG bytes if capture was successful, None otherwise
  391. """
  392. # Chamber image models: A1/P1 - returns bytes directly
  393. if is_chamber_image_model(model):
  394. logger.info("Capturing camera frame bytes from %s using chamber image protocol (model: %s)", ip_address, model)
  395. return await read_chamber_image_frame(ip_address, access_code, timeout=float(timeout))
  396. # RTSP models: X1/H2/P2 - use ffmpeg piping to stdout
  397. # TLS proxy avoids GnuTLS compatibility issues with some printer firmwares
  398. port = get_camera_port(model)
  399. proxy_port, proxy_server = await create_tls_proxy(ip_address, port)
  400. camera_url = f"rtsp://bblp:{access_code}@127.0.0.1:{proxy_port}/streaming/live/1"
  401. ffmpeg = get_ffmpeg_path()
  402. if not ffmpeg:
  403. proxy_server.close()
  404. await proxy_server.wait_closed()
  405. logger.error("ffmpeg not found for camera frame capture")
  406. return None
  407. cmd = [
  408. ffmpeg,
  409. "-y",
  410. "-rtsp_transport",
  411. "tcp",
  412. "-rtsp_flags",
  413. "prefer_tcp",
  414. "-i",
  415. camera_url,
  416. "-frames:v",
  417. "1",
  418. "-f",
  419. "image2pipe",
  420. "-vcodec",
  421. "mjpeg",
  422. "-q:v",
  423. "2",
  424. "-",
  425. ]
  426. logger.info("Capturing camera frame bytes from %s using RTSP (model: %s)", ip_address, model)
  427. process = None
  428. try:
  429. process = await asyncio.create_subprocess_exec(
  430. *cmd,
  431. stdout=asyncio.subprocess.PIPE,
  432. stderr=asyncio.subprocess.PIPE,
  433. )
  434. _active_capture_pids.add(process.pid)
  435. try:
  436. stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
  437. except TimeoutError:
  438. process.kill()
  439. await process.wait()
  440. logger.error("Camera frame bytes capture timed out after %ss", timeout)
  441. return None
  442. if process.returncode == 0 and stdout and len(stdout) >= 100:
  443. logger.info("Successfully captured camera frame bytes: %s bytes", len(stdout))
  444. return stdout
  445. else:
  446. stderr_text = stderr.decode() if stderr else "Unknown error"
  447. logger.error("ffmpeg frame bytes capture failed (code %s): %s", process.returncode, stderr_text[:200])
  448. return None
  449. except FileNotFoundError:
  450. logger.error("ffmpeg not found for camera frame capture")
  451. return None
  452. except Exception as e:
  453. logger.exception("Camera frame bytes capture failed: %s", e)
  454. return None
  455. finally:
  456. if process is not None:
  457. _active_capture_pids.discard(process.pid)
  458. proxy_server.close()
  459. await proxy_server.wait_closed()
  460. async def capture_finish_photo(
  461. printer_id: int,
  462. ip_address: str,
  463. access_code: str,
  464. model: str | None,
  465. archive_dir: Path,
  466. ) -> str | None:
  467. """Capture a finish photo and save it to the archive's photos folder.
  468. Args:
  469. printer_id: ID of the printer
  470. ip_address: Printer IP address
  471. access_code: Printer access code
  472. model: Printer model
  473. archive_dir: Directory of the archive (where the 3MF is stored)
  474. Returns:
  475. Filename of the captured photo, or None if capture failed
  476. """
  477. # Create photos subdirectory
  478. photos_dir = archive_dir / "photos"
  479. photos_dir.mkdir(parents=True, exist_ok=True)
  480. # Generate filename with timestamp
  481. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  482. filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
  483. output_path = photos_dir / filename
  484. success = await capture_camera_frame(
  485. ip_address=ip_address,
  486. access_code=access_code,
  487. model=model,
  488. output_path=output_path,
  489. timeout=30,
  490. )
  491. if success:
  492. logger.info("Finish photo saved: %s", filename)
  493. return filename
  494. else:
  495. logger.warning("Failed to capture finish photo for printer %s", printer_id)
  496. return None
  497. async def test_camera_connection(
  498. ip_address: str,
  499. access_code: str,
  500. model: str | None,
  501. ) -> dict:
  502. """Test if the camera stream is accessible.
  503. Returns dict with success status and any error message.
  504. """
  505. import tempfile
  506. fd, tmp_name = tempfile.mkstemp(suffix=".jpg")
  507. os.close(fd)
  508. test_path = Path(tmp_name)
  509. test_path.chmod(0o600)
  510. try:
  511. success = await capture_camera_frame(
  512. ip_address=ip_address,
  513. access_code=access_code,
  514. model=model,
  515. output_path=test_path,
  516. timeout=15,
  517. )
  518. if success:
  519. return {"success": True, "message": "Camera connection successful"}
  520. else:
  521. return {
  522. "success": False,
  523. "error": (
  524. "Failed to capture frame from camera. "
  525. "Ensure the printer is powered on, camera is enabled, and Developer Mode is active. "
  526. "If running in Docker, try 'network_mode: host' in docker-compose.yml."
  527. ),
  528. }
  529. finally:
  530. # Clean up test file
  531. if test_path.exists():
  532. test_path.unlink()