camera.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794
  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 subprocess
  13. import uuid
  14. from datetime import datetime
  15. from pathlib import Path
  16. from backend.app.core.logging_filters import redact_url_credentials
  17. logger = logging.getLogger(__name__)
  18. # JPEG markers
  19. JPEG_START = b"\xff\xd8"
  20. JPEG_END = b"\xff\xd9"
  21. # Cache the ffmpeg path after first lookup
  22. _ffmpeg_path: str | None = None
  23. # Cached result of rtsp_socket_timeout_flag(); see that function for context.
  24. _rtsp_socket_timeout_flag: str | None = None
  25. # Track PIDs of ffmpeg processes spawned for one-shot frame capture (snapshot).
  26. # The cleanup task in routes/camera.py checks this set to avoid killing active captures.
  27. _active_capture_pids: set[int] = set()
  28. def get_ffmpeg_path() -> str | None:
  29. """Find the ffmpeg executable path.
  30. Uses shutil.which first, then checks common installation locations
  31. for systems where PATH may be limited (e.g., systemd services).
  32. """
  33. global _ffmpeg_path
  34. if _ffmpeg_path is not None:
  35. return _ffmpeg_path
  36. # Try PATH first
  37. ffmpeg_path = shutil.which("ffmpeg")
  38. # If not found via PATH, check common installation locations
  39. if ffmpeg_path is None:
  40. common_paths = [
  41. "/usr/bin/ffmpeg",
  42. "/usr/local/bin/ffmpeg",
  43. "/opt/homebrew/bin/ffmpeg", # macOS Homebrew
  44. "/snap/bin/ffmpeg", # Ubuntu Snap
  45. "C:\\ffmpeg\\bin\\ffmpeg.exe", # Windows common
  46. ]
  47. for path in common_paths:
  48. if Path(path).exists():
  49. ffmpeg_path = path
  50. break
  51. _ffmpeg_path = ffmpeg_path
  52. if ffmpeg_path:
  53. logger.info("Found ffmpeg at: %s", ffmpeg_path)
  54. else:
  55. logger.warning("ffmpeg not found in PATH or common locations")
  56. return ffmpeg_path
  57. def rtsp_socket_timeout_flag() -> str:
  58. """Return the ffmpeg argv flag (without the leading dash) that sets the
  59. RTSP demuxer's client-side TCP socket I/O timeout, in microseconds.
  60. ffmpeg has shipped three different option arrangements for this over
  61. time, and Bambuddy supports the full range:
  62. - **Modern ffmpeg (5.x / 6.x / 7.x)** — Debian 13, Ubuntu 24.04, current
  63. Homebrew, etc. ``-timeout`` is the socket I/O timeout (microseconds);
  64. ``-stimeout`` was REMOVED.
  65. - **Transitional ffmpeg (~late-4.x, some 5.x builds)** — Ubuntu 22.04's
  66. shipped version is one of these. ``-timeout`` was deprecated and
  67. *repurposed* to mean the RTSP listen-mode incoming-connection
  68. timeout — and any non-zero value implies ``-listen``, which makes
  69. ffmpeg bind the localhost proxy port and fail with EADDRINUSE
  70. (#1504). ``-stimeout`` was the replacement socket I/O timeout in
  71. that window.
  72. - **Old ffmpeg (early 4.x and earlier)** — ``-timeout`` is socket I/O
  73. timeout (the original meaning, before the deprecation churn).
  74. We probe ``-h demuxer=rtsp`` once and cache: if ``-stimeout`` is
  75. advertised, prefer it (covers the transitional window and stays
  76. correct on the older builds that still accept it as an alias); else
  77. fall back to ``-timeout`` (correct on modern and pre-deprecation
  78. ffmpeg). The result is cached for the process lifetime — ffmpeg
  79. isn't going to swap mid-run.
  80. Returns the option name without the leading dash, e.g. ``"timeout"``
  81. or ``"stimeout"``. Callers must prepend ``-`` themselves so a string
  82. formatting bug can't pass an empty flag.
  83. """
  84. global _rtsp_socket_timeout_flag
  85. if _rtsp_socket_timeout_flag is not None:
  86. return _rtsp_socket_timeout_flag
  87. ffmpeg = get_ffmpeg_path()
  88. chosen = "timeout" # safe default for modern ffmpeg
  89. if ffmpeg:
  90. try:
  91. result = subprocess.run(
  92. [ffmpeg, "-hide_banner", "-h", "demuxer=rtsp"],
  93. capture_output=True,
  94. text=True,
  95. timeout=5,
  96. check=False,
  97. )
  98. help_text = (result.stdout or "") + (result.stderr or "")
  99. # Help lines list each option as `-<name> ` (trailing space) — match
  100. # that exact form so we don't accidentally hit a substring elsewhere.
  101. if "-stimeout " in help_text:
  102. chosen = "stimeout"
  103. except (OSError, subprocess.SubprocessError) as exc:
  104. # If probing fails, keep the modern-ffmpeg default. Worst case
  105. # is the EADDRINUSE regression returns for transitional-ffmpeg
  106. # users — same as before this function existed.
  107. logger.warning("Could not probe ffmpeg RTSP timeout flag, defaulting to -timeout: %s", exc)
  108. _rtsp_socket_timeout_flag = chosen
  109. logger.info("RTSP socket I/O timeout flag: -%s", chosen)
  110. return chosen
  111. def supports_rtsp(model: str | None) -> bool:
  112. """Check if printer model supports RTSP camera streaming.
  113. RTSP supported: X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, P2S
  114. Chamber image only: A1, A1MINI, P1P, P1S
  115. Note: Model can be either display name (e.g., "P2S") or internal code (e.g., "N7").
  116. Internal codes from MQTT/SSDP:
  117. - BL-P001: X1/X1C
  118. - C13: X1E
  119. - N6: X2D
  120. - O1D: H2D
  121. - O1C, O1C2: H2C
  122. - O1S: H2S
  123. - O1E, O2D: H2D Pro
  124. - N7: P2S
  125. """
  126. if model:
  127. model_upper = model.upper()
  128. # Display names: X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, P2S
  129. if model_upper.startswith(("X1", "X2", "H2", "P2")):
  130. return True
  131. # Internal codes for RTSP models
  132. if model_upper in ("BL-P001", "C13", "N6", "O1D", "O1C", "O1C2", "O1S", "O1E", "O2D", "N7"):
  133. return True
  134. # A1/P1 and unknown models use chamber image protocol
  135. return False
  136. def get_camera_port(model: str | None) -> int:
  137. """Get the camera port based on printer model.
  138. X1/X2/H2/P2 series use RTSP on port 322.
  139. A1/P1 series use chamber image protocol on port 6000.
  140. """
  141. if supports_rtsp(model):
  142. return 322
  143. return 6000
  144. def rewrite_rtsp_request_url(data: bytes, proxy_url: bytes, real_url: bytes) -> bytes:
  145. """Rewrite RTSP request-line URLs, leaving other lines (e.g. Authorization) intact.
  146. RTSP request lines have the form ``METHOD <url> RTSP/1.0\\r\\n``.
  147. Only those lines are modified so that Digest auth headers (which embed
  148. the original URL and a cryptographic hash) are not broken.
  149. """
  150. rtsp_marker = b" RTSP/1.0"
  151. if rtsp_marker not in data:
  152. return data
  153. lines = data.split(b"\r\n")
  154. for i, line in enumerate(lines):
  155. if line.endswith(rtsp_marker):
  156. lines[i] = line.replace(proxy_url, real_url)
  157. break
  158. return b"\r\n".join(lines)
  159. async def create_tls_proxy(target_host: str, target_port: int) -> tuple[int, "asyncio.Server"]:
  160. """Create a local TCP→TLS proxy for RTSP streams.
  161. Bambu printers use RTSPS (RTSP over TLS) with self-signed certificates.
  162. The Debian ffmpeg package uses GnuTLS, whose hardened defaults reject
  163. certain TLS behaviors (renegotiation, legacy ciphers) that some printer
  164. firmwares (notably P2S) rely on. This causes streams to drop after a
  165. few seconds.
  166. This proxy terminates TLS using Python's ssl module (OpenSSL), which is
  167. more permissive, and exposes a plain TCP port that ffmpeg connects to
  168. with ``rtsp://`` instead of ``rtsps://``.
  169. RTSP embeds URLs in protocol messages (DESCRIBE, SETUP, PLAY). The proxy
  170. rewrites ``127.0.0.1:<proxy_port>`` → ``<target_host>:<target_port>`` in
  171. client→server data so the printer recognises the stream path.
  172. Returns ``(local_port, server)``. Caller must close the server when done.
  173. """
  174. ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
  175. ssl_ctx.check_hostname = False
  176. ssl_ctx.verify_mode = ssl.CERT_NONE
  177. # Filled in after the server socket is created (handler only runs after).
  178. _local_port: list[int] = [0]
  179. async def _handle(client_reader: asyncio.StreamReader, client_writer: asyncio.StreamWriter):
  180. tls_writer = None
  181. try:
  182. tls_reader, tls_writer = await asyncio.wait_for(
  183. asyncio.open_connection(target_host, target_port, ssl=ssl_ctx),
  184. timeout=10.0,
  185. )
  186. # URL patterns for RTSP request-line rewriting.
  187. proxy_url = f"rtsp://127.0.0.1:{_local_port[0]}".encode()
  188. real_url = f"rtsps://{target_host}:{target_port}".encode()
  189. # Note on the broad except below: dst.write() raises RuntimeError
  190. # under uvloop when the underlying handle has already been torn
  191. # down (uvloop.loop.UVHandle._ensure_alive). asyncio's default
  192. # selector loop reports the same situation as ConnectionResetError
  193. # / OSError, so a tuple that doesn't include RuntimeError leaks the
  194. # uvloop variant up to asyncio's unhandled-exception logger
  195. # ("Unhandled exception in client_connected_cb"). The forwarders
  196. # are intentionally fire-and-forget on tear-down — once either
  197. # peer drops, both halves of the proxy should exit quietly.
  198. async def _fwd_to_server(src: asyncio.StreamReader, dst: asyncio.StreamWriter):
  199. """Forward client→server, rewriting RTSP request-line URLs only."""
  200. try:
  201. while True:
  202. data = await src.read(65536)
  203. if not data:
  204. break
  205. data = rewrite_rtsp_request_url(data, proxy_url, real_url)
  206. dst.write(data)
  207. await dst.drain()
  208. except (ConnectionError, OSError, asyncio.CancelledError, RuntimeError):
  209. pass
  210. finally:
  211. if not dst.is_closing():
  212. try:
  213. dst.close()
  214. except OSError:
  215. pass
  216. async def _fwd_to_client(src: asyncio.StreamReader, dst: asyncio.StreamWriter):
  217. """Forward server→client unchanged."""
  218. try:
  219. while True:
  220. data = await src.read(65536)
  221. if not data:
  222. break
  223. dst.write(data)
  224. await dst.drain()
  225. except (ConnectionError, OSError, asyncio.CancelledError, RuntimeError):
  226. pass
  227. finally:
  228. if not dst.is_closing():
  229. try:
  230. dst.close()
  231. except OSError:
  232. pass
  233. await asyncio.gather(
  234. _fwd_to_server(client_reader, tls_writer),
  235. _fwd_to_client(tls_reader, client_writer),
  236. )
  237. except (ConnectionError, OSError, TimeoutError) as e:
  238. logger.debug("TLS proxy connection to %s:%s failed: %s", target_host, target_port, e)
  239. finally:
  240. for w in (client_writer, tls_writer):
  241. if w and not w.is_closing():
  242. try:
  243. w.close()
  244. except OSError:
  245. pass
  246. server = await asyncio.start_server(_handle, "127.0.0.1", 0)
  247. _local_port[0] = server.sockets[0].getsockname()[1]
  248. logger.debug("TLS proxy for %s:%s listening on 127.0.0.1:%s", target_host, target_port, _local_port[0])
  249. return _local_port[0], server
  250. def is_chamber_image_model(model: str | None) -> bool:
  251. """Check if printer uses chamber image protocol instead of RTSP.
  252. A1, A1MINI, P1P, P1S use the chamber image protocol on port 6000.
  253. """
  254. return not supports_rtsp(model)
  255. def build_camera_url(ip_address: str, access_code: str, model: str | None) -> str:
  256. """Build the RTSPS URL for the printer camera (RTSP models only)."""
  257. port = get_camera_port(model)
  258. return f"rtsps://bblp:{access_code}@{ip_address}:{port}/streaming/live/1"
  259. def _create_chamber_auth_payload(access_code: str) -> bytes:
  260. """Create the 80-byte authentication payload for chamber image protocol.
  261. Format:
  262. - Bytes 0-3: 0x40 0x00 0x00 0x00 (magic)
  263. - Bytes 4-7: 0x00 0x30 0x00 0x00 (command)
  264. - Bytes 8-15: zeros (padding)
  265. - Bytes 16-47: username "bblp" (32 bytes, null-padded)
  266. - Bytes 48-79: access code (32 bytes, null-padded)
  267. """
  268. username = b"bblp"
  269. access_code_bytes = access_code.encode("utf-8")
  270. # Build the 80-byte payload
  271. payload = struct.pack(
  272. "<II8s32s32s",
  273. 0x40, # Magic header
  274. 0x3000, # Command
  275. b"\x00" * 8, # Padding
  276. username.ljust(32, b"\x00"), # Username padded to 32 bytes
  277. access_code_bytes.ljust(32, b"\x00"), # Access code padded to 32 bytes
  278. )
  279. return payload
  280. def _create_ssl_context() -> ssl.SSLContext:
  281. """Create an SSL context for chamber image connection.
  282. Bambu printers use self-signed certificates, so we disable verification.
  283. """
  284. ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
  285. ctx.check_hostname = False
  286. ctx.verify_mode = ssl.CERT_NONE
  287. return ctx
  288. async def read_chamber_image_frame(
  289. ip_address: str,
  290. access_code: str,
  291. timeout: float = 10.0,
  292. ) -> bytes | None:
  293. """Read a single JPEG frame from the chamber image protocol.
  294. This is used by A1/P1 printers which don't support RTSP.
  295. Args:
  296. ip_address: Printer IP address
  297. access_code: Printer access code
  298. timeout: Connection timeout in seconds
  299. Returns:
  300. JPEG image data or None if failed
  301. """
  302. port = 6000
  303. ssl_context = _create_ssl_context()
  304. try:
  305. # Connect with SSL
  306. reader, writer = await asyncio.wait_for(
  307. asyncio.open_connection(ip_address, port, ssl=ssl_context),
  308. timeout=timeout,
  309. )
  310. try:
  311. # Send authentication payload
  312. auth_payload = _create_chamber_auth_payload(access_code)
  313. writer.write(auth_payload)
  314. await writer.drain()
  315. # Read the 16-byte header
  316. header = await asyncio.wait_for(reader.readexactly(16), timeout=timeout)
  317. if len(header) < 16:
  318. logger.error("Chamber image: incomplete header received")
  319. return None
  320. # Parse payload size from header (little-endian uint32 at offset 0)
  321. payload_size = struct.unpack("<I", header[0:4])[0]
  322. if payload_size == 0 or payload_size > 10_000_000: # Sanity check: max 10MB
  323. logger.error("Chamber image: invalid payload size %s", payload_size)
  324. return None
  325. # Read the JPEG data
  326. jpeg_data = await asyncio.wait_for(
  327. reader.readexactly(payload_size),
  328. timeout=timeout,
  329. )
  330. # Validate JPEG markers
  331. if not jpeg_data.startswith(JPEG_START):
  332. logger.error("Chamber image: data is not a valid JPEG (missing start marker)")
  333. return None
  334. if not jpeg_data.endswith(JPEG_END):
  335. logger.warning("Chamber image: JPEG missing end marker, may be truncated")
  336. logger.debug("Chamber image: received %s bytes", len(jpeg_data))
  337. return jpeg_data
  338. finally:
  339. writer.close()
  340. try:
  341. await writer.wait_closed()
  342. except OSError:
  343. pass # Socket already closed; cleanup is best-effort
  344. except TimeoutError:
  345. logger.error("Chamber image: connection timeout to %s:%s", ip_address, port)
  346. return None
  347. except ConnectionRefusedError:
  348. logger.error("Chamber image: connection refused by %s:%s", ip_address, port)
  349. return None
  350. except Exception as e:
  351. logger.exception("Chamber image: error connecting to %s:%s: %s", ip_address, port, e)
  352. return None
  353. async def generate_chamber_image_stream(
  354. ip_address: str,
  355. access_code: str,
  356. fps: int = 5,
  357. ) -> asyncio.StreamReader | None:
  358. """Create a persistent connection for streaming chamber images.
  359. Returns a connected reader or None if connection failed.
  360. """
  361. port = 6000
  362. ssl_context = _create_ssl_context()
  363. try:
  364. reader, writer = await asyncio.wait_for(
  365. asyncio.open_connection(ip_address, port, ssl=ssl_context),
  366. timeout=10.0,
  367. )
  368. # Send authentication payload
  369. auth_payload = _create_chamber_auth_payload(access_code)
  370. writer.write(auth_payload)
  371. await writer.drain()
  372. logger.info("Chamber image: connected to %s:%s", ip_address, port)
  373. return reader, writer
  374. except Exception as e:
  375. logger.error("Chamber image: failed to connect to %s:%s: %s", ip_address, port, e)
  376. return None
  377. async def read_next_chamber_frame(reader: asyncio.StreamReader, timeout: float = 10.0) -> bytes | None:
  378. """Read the next JPEG frame from an established chamber image connection."""
  379. try:
  380. # Read the 16-byte header
  381. header = await asyncio.wait_for(reader.readexactly(16), timeout=timeout)
  382. # Parse payload size from header (little-endian uint32 at offset 0)
  383. payload_size = struct.unpack("<I", header[0:4])[0]
  384. if payload_size == 0 or payload_size > 10_000_000:
  385. logger.error("Chamber image: invalid payload size %s", payload_size)
  386. return None
  387. # Read the JPEG data
  388. jpeg_data = await asyncio.wait_for(
  389. reader.readexactly(payload_size),
  390. timeout=timeout,
  391. )
  392. return jpeg_data
  393. except asyncio.IncompleteReadError:
  394. logger.warning("Chamber image: connection closed by printer")
  395. return None
  396. except TimeoutError:
  397. logger.warning("Chamber image: read timeout")
  398. return None
  399. except Exception as e:
  400. logger.error("Chamber image: error reading frame: %s", e)
  401. return None
  402. async def capture_camera_frame(
  403. ip_address: str,
  404. access_code: str,
  405. model: str | None,
  406. output_path: Path,
  407. timeout: int = 30,
  408. ) -> bool:
  409. """Capture a single frame from the printer's camera stream and save to disk.
  410. Uses capture_camera_frame_bytes() internally for protocol selection,
  411. then writes the result to the specified output path.
  412. Args:
  413. ip_address: Printer IP address
  414. access_code: Printer access code
  415. model: Printer model (X1, H2D, P1, A1, etc.)
  416. output_path: Path where to save the captured image
  417. timeout: Timeout in seconds for the capture operation
  418. Returns:
  419. True if capture was successful, False otherwise
  420. """
  421. output_path.parent.mkdir(parents=True, exist_ok=True)
  422. jpeg_data = await capture_camera_frame_bytes(ip_address, access_code, model, timeout)
  423. if jpeg_data:
  424. try:
  425. with open(output_path, "wb") as f:
  426. f.write(jpeg_data)
  427. logger.info("Saved camera frame to: %s", output_path)
  428. return True
  429. except OSError as e:
  430. logger.error("Failed to write camera frame: %s", e)
  431. return False
  432. return False
  433. async def capture_camera_frame_bytes(
  434. ip_address: str,
  435. access_code: str,
  436. model: str | None,
  437. timeout: int = 15,
  438. ) -> bytes | None:
  439. """Capture a single frame and return as JPEG bytes (no disk write).
  440. Uses the same protocol selection as capture_camera_frame but returns
  441. bytes directly instead of writing to disk.
  442. Args:
  443. ip_address: Printer IP address
  444. access_code: Printer access code
  445. model: Printer model (X1, H2D, P1, A1, etc.)
  446. timeout: Timeout in seconds for the capture operation
  447. Returns:
  448. JPEG bytes if capture was successful, None otherwise
  449. """
  450. # Chamber image models: A1/P1 - returns bytes directly
  451. if is_chamber_image_model(model):
  452. logger.info("Capturing camera frame bytes from %s using chamber image protocol (model: %s)", ip_address, model)
  453. return await read_chamber_image_frame(ip_address, access_code, timeout=float(timeout))
  454. # RTSP models: X1/H2/P2 - use ffmpeg piping to stdout
  455. # TLS proxy avoids GnuTLS compatibility issues with some printer firmwares
  456. port = get_camera_port(model)
  457. proxy_port, proxy_server = await create_tls_proxy(ip_address, port)
  458. camera_url = f"rtsp://bblp:{access_code}@127.0.0.1:{proxy_port}/streaming/live/1"
  459. ffmpeg = get_ffmpeg_path()
  460. if not ffmpeg:
  461. proxy_server.close()
  462. await proxy_server.wait_closed()
  463. logger.error("ffmpeg not found for camera frame capture")
  464. return None
  465. cmd = [
  466. ffmpeg,
  467. "-y",
  468. "-rtsp_transport",
  469. "tcp",
  470. "-rtsp_flags",
  471. "prefer_tcp",
  472. "-i",
  473. camera_url,
  474. "-frames:v",
  475. "1",
  476. "-f",
  477. "image2pipe",
  478. "-vcodec",
  479. "mjpeg",
  480. "-q:v",
  481. "2",
  482. "-",
  483. ]
  484. logger.info("Capturing camera frame bytes from %s using RTSP (model: %s)", ip_address, model)
  485. process = None
  486. try:
  487. process = await asyncio.create_subprocess_exec(
  488. *cmd,
  489. stdout=asyncio.subprocess.PIPE,
  490. stderr=asyncio.subprocess.PIPE,
  491. )
  492. _active_capture_pids.add(process.pid)
  493. try:
  494. stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
  495. except TimeoutError:
  496. process.kill()
  497. await process.wait()
  498. logger.error("Camera frame bytes capture timed out after %ss", timeout)
  499. return None
  500. if process.returncode == 0 and stdout and len(stdout) >= 100:
  501. logger.info("Successfully captured camera frame bytes: %s bytes", len(stdout))
  502. return stdout
  503. else:
  504. # ffmpeg echoes the RTSP input URL, which carries the access code.
  505. stderr_text = redact_url_credentials(stderr.decode()) if stderr else "Unknown error"
  506. logger.error("ffmpeg frame bytes capture failed (code %s): %s", process.returncode, stderr_text[:200])
  507. return None
  508. except FileNotFoundError:
  509. logger.error("ffmpeg not found for camera frame capture")
  510. return None
  511. except Exception as e:
  512. logger.exception("Camera frame bytes capture failed: %s", e)
  513. return None
  514. finally:
  515. if process is not None:
  516. _active_capture_pids.discard(process.pid)
  517. proxy_server.close()
  518. await proxy_server.wait_closed()
  519. async def extract_video_last_frame(video_path: Path, output_path: Path) -> bool:
  520. """Extract the last frame of `video_path` as JPEG at `output_path`.
  521. Used to source finish photos from a Bambu timelapse. The Bambu firmware
  522. stops timelapse recording AFTER the toolhead parks but BEFORE the bed-drop
  523. end-gcode runs, so the last frame frames the finished print correctly.
  524. A live camera grab at `gcode_state=FINISH` captures the bed already
  525. lowered (#1397).
  526. Implementation: ``-update 1`` writes each decoded frame to the same
  527. output file (overwriting), so the file left on disk after ffmpeg
  528. finishes is the LAST frame. This works regardless of how short the
  529. video is — a small print's timelapse can be sub-second / sub-30 frames
  530. (one frame per layer-change capture), and the earlier ``-sseof -1.0``
  531. approach failed there because the seek went before the start of the
  532. file and ffmpeg silently returned frame 0 (empty bed at print start).
  533. Decoding every frame is fine: Bambu timelapses are short by
  534. construction (<1 minute even on hours-long prints).
  535. Returns False on missing ffmpeg, missing video, subprocess failure or
  536. timeout. Never raises.
  537. """
  538. ffmpeg = get_ffmpeg_path()
  539. if not ffmpeg:
  540. logger.warning("Cannot extract video last frame: ffmpeg not available")
  541. return False
  542. if not video_path.exists() or video_path.stat().st_size == 0:
  543. logger.warning("Cannot extract last frame: %s missing or empty", video_path)
  544. return False
  545. output_path.parent.mkdir(parents=True, exist_ok=True)
  546. cmd = [
  547. ffmpeg,
  548. "-y",
  549. "-i",
  550. str(video_path),
  551. "-q:v",
  552. "2",
  553. "-update",
  554. "1",
  555. str(output_path),
  556. ]
  557. process = None
  558. try:
  559. process = await asyncio.create_subprocess_exec(
  560. *cmd,
  561. stdout=asyncio.subprocess.PIPE,
  562. stderr=asyncio.subprocess.PIPE,
  563. )
  564. _, stderr = await asyncio.wait_for(process.communicate(), timeout=15.0)
  565. if process.returncode != 0:
  566. logger.warning(
  567. "ffmpeg failed extracting last frame from %s: %s",
  568. video_path,
  569. stderr.decode(errors="replace")[:500],
  570. )
  571. return False
  572. if not output_path.exists() or output_path.stat().st_size == 0:
  573. logger.warning("ffmpeg produced no output for %s", video_path)
  574. return False
  575. return True
  576. except asyncio.TimeoutError:
  577. logger.warning("ffmpeg timed out extracting last frame from %s", video_path)
  578. if process is not None:
  579. try:
  580. process.kill()
  581. await process.wait()
  582. except ProcessLookupError:
  583. pass # Already exited
  584. return False
  585. except OSError as e:
  586. logger.warning("ffmpeg subprocess error for %s: %s", video_path, e)
  587. return False
  588. async def capture_finish_photo(
  589. printer_id: int,
  590. ip_address: str,
  591. access_code: str,
  592. model: str | None,
  593. archive_dir: Path,
  594. ) -> str | None:
  595. """Capture a finish photo and save it to the archive's photos folder.
  596. Args:
  597. printer_id: ID of the printer
  598. ip_address: Printer IP address
  599. access_code: Printer access code
  600. model: Printer model
  601. archive_dir: Directory of the archive (where the 3MF is stored)
  602. Returns:
  603. Filename of the captured photo, or None if capture failed
  604. """
  605. # Create photos subdirectory
  606. photos_dir = archive_dir / "photos"
  607. photos_dir.mkdir(parents=True, exist_ok=True)
  608. # Generate filename with timestamp
  609. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  610. filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
  611. output_path = (
  612. photos_dir / filename
  613. ) # SEC-PATH-OK: filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg" generated above
  614. success = await capture_camera_frame(
  615. ip_address=ip_address,
  616. access_code=access_code,
  617. model=model,
  618. output_path=output_path,
  619. timeout=30,
  620. )
  621. if success:
  622. logger.info("Finish photo saved: %s", filename)
  623. return filename
  624. else:
  625. logger.warning("Failed to capture finish photo for printer %s", printer_id)
  626. return None
  627. async def test_camera_connection(
  628. ip_address: str,
  629. access_code: str,
  630. model: str | None,
  631. ) -> dict:
  632. """Test if the camera stream is accessible.
  633. Returns dict with success status and any error message.
  634. """
  635. import tempfile
  636. fd, tmp_name = tempfile.mkstemp(suffix=".jpg")
  637. os.close(fd)
  638. test_path = Path(tmp_name)
  639. test_path.chmod(0o600)
  640. try:
  641. success = await capture_camera_frame(
  642. ip_address=ip_address,
  643. access_code=access_code,
  644. model=model,
  645. output_path=test_path,
  646. timeout=15,
  647. )
  648. if success:
  649. return {"success": True, "message": "Camera connection successful"}
  650. else:
  651. return {
  652. "success": False,
  653. "error": (
  654. "Failed to capture frame from camera. "
  655. "Ensure the printer is powered on, camera is enabled, and Developer Mode is active. "
  656. "If running in Docker, try 'network_mode: host' in docker-compose.yml."
  657. ),
  658. }
  659. finally:
  660. # Clean up test file
  661. if test_path.exists():
  662. test_path.unlink()