camera.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988
  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 functools
  8. import logging
  9. import os
  10. import shutil
  11. import ssl
  12. import struct
  13. import subprocess
  14. import uuid
  15. from datetime import datetime
  16. from pathlib import Path
  17. from backend.app.core.logging_filters import redact_url_credentials
  18. logger = logging.getLogger(__name__)
  19. # JPEG markers
  20. JPEG_START = b"\xff\xd8"
  21. JPEG_END = b"\xff\xd9"
  22. # Cache the ffmpeg path after first lookup
  23. _ffmpeg_path: str | None = None
  24. # Cached result of rtsp_socket_timeout_flag(); see that function for context.
  25. _rtsp_socket_timeout_flag: str | None = None
  26. # Track PIDs of ffmpeg processes spawned for one-shot frame capture (snapshot).
  27. # The cleanup task in routes/camera.py checks this set to avoid killing active captures.
  28. _active_capture_pids: set[int] = set()
  29. # In-flight one-shot captures, keyed by printer IP (#2705).
  30. #
  31. # Bambu firmware allows exactly one camera connection, and the existing guards
  32. # (is_stream_active / try_get_active_buffered_frame, #1271 + #1348) only stop a
  33. # capturer from competing with the fan-out BROADCASTER. They do nothing for
  34. # capturer-vs-capturer with no viewer attached, where every consumer correctly
  35. # concludes it isn't competing with a viewer and then collides with the others.
  36. # Eight paths reach capture_camera_frame_bytes() independently — Obico polling,
  37. # /camera/snapshot, the finish-photo moment and its disk-writing sibling, plate
  38. # detection, the camera test and the diagnose tool — so the single-flight lives
  39. # at the bottom of the stack and needs no call-site changes.
  40. #
  41. # Keyed by IP rather than printer_id because IP is what the firmware's one-
  42. # connection limit applies to: two printer rows pointing at the same address
  43. # still share one camera. (This function never sees a printer_id anyway.) The
  44. # key deliberately excludes the timeout, or callers that disagree about it —
  45. # and they all do, from 10s to 30s — would never coalesce, which is exactly
  46. # the Obico-vs-snapshot pair from the report.
  47. _inflight_captures: dict[str, asyncio.Task[bytes | None]] = {}
  48. def get_ffmpeg_path() -> str | None:
  49. """Find the ffmpeg executable path.
  50. Uses shutil.which first, then checks common installation locations
  51. for systems where PATH may be limited (e.g., systemd services).
  52. """
  53. global _ffmpeg_path
  54. if _ffmpeg_path is not None:
  55. return _ffmpeg_path
  56. # Try PATH first
  57. ffmpeg_path = shutil.which("ffmpeg")
  58. # If not found via PATH, check common installation locations
  59. if ffmpeg_path is None:
  60. common_paths = [
  61. "/usr/bin/ffmpeg",
  62. "/usr/local/bin/ffmpeg",
  63. "/opt/homebrew/bin/ffmpeg", # macOS Homebrew
  64. "/snap/bin/ffmpeg", # Ubuntu Snap
  65. "C:\\ffmpeg\\bin\\ffmpeg.exe", # Windows common
  66. ]
  67. for path in common_paths:
  68. if Path(path).exists():
  69. ffmpeg_path = path
  70. break
  71. _ffmpeg_path = ffmpeg_path
  72. if ffmpeg_path:
  73. logger.info("Found ffmpeg at: %s", ffmpeg_path)
  74. else:
  75. logger.warning("ffmpeg not found in PATH or common locations")
  76. return ffmpeg_path
  77. def rtsp_socket_timeout_flag() -> str:
  78. """Return the ffmpeg argv flag (without the leading dash) that sets the
  79. RTSP demuxer's client-side TCP socket I/O timeout, in microseconds.
  80. ffmpeg has shipped three different option arrangements for this over
  81. time, and Bambuddy supports the full range:
  82. - **Modern ffmpeg (5.x / 6.x / 7.x)** — Debian 13, Ubuntu 24.04, current
  83. Homebrew, etc. ``-timeout`` is the socket I/O timeout (microseconds);
  84. ``-stimeout`` was REMOVED.
  85. - **Transitional ffmpeg (~late-4.x, some 5.x builds)** — Ubuntu 22.04's
  86. shipped version is one of these. ``-timeout`` was deprecated and
  87. *repurposed* to mean the RTSP listen-mode incoming-connection
  88. timeout — and any non-zero value implies ``-listen``, which makes
  89. ffmpeg bind the localhost proxy port and fail with EADDRINUSE
  90. (#1504). ``-stimeout`` was the replacement socket I/O timeout in
  91. that window.
  92. - **Old ffmpeg (early 4.x and earlier)** — ``-timeout`` is socket I/O
  93. timeout (the original meaning, before the deprecation churn).
  94. We probe ``-h demuxer=rtsp`` once and cache: if ``-stimeout`` is
  95. advertised, prefer it (covers the transitional window and stays
  96. correct on the older builds that still accept it as an alias); else
  97. fall back to ``-timeout`` (correct on modern and pre-deprecation
  98. ffmpeg). The result is cached for the process lifetime — ffmpeg
  99. isn't going to swap mid-run.
  100. Returns the option name without the leading dash, e.g. ``"timeout"``
  101. or ``"stimeout"``. Callers must prepend ``-`` themselves so a string
  102. formatting bug can't pass an empty flag.
  103. """
  104. global _rtsp_socket_timeout_flag
  105. if _rtsp_socket_timeout_flag is not None:
  106. return _rtsp_socket_timeout_flag
  107. ffmpeg = get_ffmpeg_path()
  108. chosen = "timeout" # safe default for modern ffmpeg
  109. if ffmpeg:
  110. try:
  111. result = subprocess.run(
  112. [ffmpeg, "-hide_banner", "-h", "demuxer=rtsp"],
  113. capture_output=True,
  114. text=True,
  115. timeout=5,
  116. check=False,
  117. )
  118. help_text = (result.stdout or "") + (result.stderr or "")
  119. # Help lines list each option as `-<name> ` (trailing space) — match
  120. # that exact form so we don't accidentally hit a substring elsewhere.
  121. if "-stimeout " in help_text:
  122. chosen = "stimeout"
  123. except (OSError, subprocess.SubprocessError) as exc:
  124. # If probing fails, keep the modern-ffmpeg default. Worst case
  125. # is the EADDRINUSE regression returns for transitional-ffmpeg
  126. # users — same as before this function existed.
  127. logger.warning("Could not probe ffmpeg RTSP timeout flag, defaulting to -timeout: %s", exc)
  128. _rtsp_socket_timeout_flag = chosen
  129. logger.info("RTSP socket I/O timeout flag: -%s", chosen)
  130. return chosen
  131. def supports_rtsp(model: str | None) -> bool:
  132. """Check if printer model supports RTSP camera streaming.
  133. RTSP supported: X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, P2S
  134. Chamber image only: A1, A1MINI, P1P, P1S
  135. Note: Model can be either display name (e.g., "P2S") or internal code (e.g., "N7").
  136. Internal codes from MQTT/SSDP:
  137. - BL-P001: X1/X1C
  138. - C13: X1E
  139. - N6: X2D
  140. - O1D: H2D
  141. - O1C, O1C2: H2C
  142. - O1S: H2S
  143. - O1E, O2D: H2D Pro
  144. - N7: P2S
  145. """
  146. if model:
  147. model_upper = model.upper()
  148. # Display names: X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, P2S
  149. if model_upper.startswith(("X1", "X2", "H2", "P2")):
  150. return True
  151. # Internal codes for RTSP models
  152. if model_upper in ("BL-P001", "C13", "N6", "O1D", "O1C", "O1C2", "O1S", "O1E", "O2D", "N7"):
  153. return True
  154. # A1/P1 and unknown models use chamber image protocol
  155. return False
  156. def get_camera_port(model: str | None) -> int:
  157. """Get the camera port based on printer model.
  158. X1/X2/H2/P2 series use RTSP on port 322.
  159. A1/P1 series use chamber image protocol on port 6000.
  160. """
  161. if supports_rtsp(model):
  162. return 322
  163. return 6000
  164. def rewrite_rtsp_request_url(data: bytes, proxy_url: bytes, real_url: bytes) -> bytes:
  165. """Rewrite RTSP request-line URLs, leaving other lines (e.g. Authorization) intact.
  166. RTSP request lines have the form ``METHOD <url> RTSP/1.0\\r\\n``.
  167. Only those lines are modified so that Digest auth headers (which embed
  168. the original URL and a cryptographic hash) are not broken.
  169. """
  170. rtsp_marker = b" RTSP/1.0"
  171. if rtsp_marker not in data:
  172. return data
  173. lines = data.split(b"\r\n")
  174. for i, line in enumerate(lines):
  175. if line.endswith(rtsp_marker):
  176. lines[i] = line.replace(proxy_url, real_url)
  177. break
  178. return b"\r\n".join(lines)
  179. async def create_tls_proxy(target_host: str, target_port: int) -> tuple[int, "asyncio.Server"]:
  180. """Create a local TCP→TLS proxy for RTSP streams.
  181. Bambu printers use RTSPS (RTSP over TLS) with self-signed certificates.
  182. The Debian ffmpeg package uses GnuTLS, whose hardened defaults reject
  183. certain TLS behaviors (renegotiation, legacy ciphers) that some printer
  184. firmwares (notably P2S) rely on. This causes streams to drop after a
  185. few seconds.
  186. This proxy terminates TLS using Python's ssl module (OpenSSL), which is
  187. more permissive, and exposes a plain TCP port that ffmpeg connects to
  188. with ``rtsp://`` instead of ``rtsps://``.
  189. RTSP embeds URLs in protocol messages (DESCRIBE, SETUP, PLAY). The proxy
  190. rewrites ``127.0.0.1:<proxy_port>`` → ``<target_host>:<target_port>`` in
  191. client→server data so the printer recognises the stream path.
  192. Returns ``(local_port, server)``. Caller must close the server when done.
  193. """
  194. ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
  195. ssl_ctx.check_hostname = False
  196. ssl_ctx.verify_mode = ssl.CERT_NONE
  197. # Filled in after the server socket is created (handler only runs after).
  198. _local_port: list[int] = [0]
  199. async def _handle(client_reader: asyncio.StreamReader, client_writer: asyncio.StreamWriter):
  200. tls_writer = None
  201. try:
  202. tls_reader, tls_writer = await asyncio.wait_for(
  203. asyncio.open_connection(target_host, target_port, ssl=ssl_ctx),
  204. timeout=10.0,
  205. )
  206. # URL patterns for RTSP request-line rewriting.
  207. proxy_url = f"rtsp://127.0.0.1:{_local_port[0]}".encode()
  208. real_url = f"rtsps://{target_host}:{target_port}".encode()
  209. # Note on the broad except below: dst.write() raises RuntimeError
  210. # under uvloop when the underlying handle has already been torn
  211. # down (uvloop.loop.UVHandle._ensure_alive). asyncio's default
  212. # selector loop reports the same situation as ConnectionResetError
  213. # / OSError, so a tuple that doesn't include RuntimeError leaks the
  214. # uvloop variant up to asyncio's unhandled-exception logger
  215. # ("Unhandled exception in client_connected_cb"). The forwarders
  216. # are intentionally fire-and-forget on tear-down — once either
  217. # peer drops, both halves of the proxy should exit quietly.
  218. async def _fwd_to_server(src: asyncio.StreamReader, dst: asyncio.StreamWriter):
  219. """Forward client→server, rewriting RTSP request-line URLs only."""
  220. try:
  221. while True:
  222. data = await src.read(65536)
  223. if not data:
  224. break
  225. data = rewrite_rtsp_request_url(data, proxy_url, real_url)
  226. dst.write(data)
  227. await dst.drain()
  228. except (ConnectionError, OSError, asyncio.CancelledError, RuntimeError):
  229. pass
  230. finally:
  231. if not dst.is_closing():
  232. try:
  233. dst.close()
  234. except OSError:
  235. pass
  236. async def _fwd_to_client(src: asyncio.StreamReader, dst: asyncio.StreamWriter):
  237. """Forward server→client unchanged."""
  238. try:
  239. while True:
  240. data = await src.read(65536)
  241. if not data:
  242. break
  243. dst.write(data)
  244. await dst.drain()
  245. except (ConnectionError, OSError, asyncio.CancelledError, RuntimeError):
  246. pass
  247. finally:
  248. if not dst.is_closing():
  249. try:
  250. dst.close()
  251. except OSError:
  252. pass
  253. await asyncio.gather(
  254. _fwd_to_server(client_reader, tls_writer),
  255. _fwd_to_client(tls_reader, client_writer),
  256. )
  257. except (ConnectionError, OSError, TimeoutError) as e:
  258. logger.debug("TLS proxy connection to %s:%s failed: %s", target_host, target_port, e)
  259. finally:
  260. for w in (client_writer, tls_writer):
  261. if w and not w.is_closing():
  262. try:
  263. w.close()
  264. except OSError:
  265. pass
  266. server = await asyncio.start_server(_handle, "127.0.0.1", 0)
  267. _local_port[0] = server.sockets[0].getsockname()[1]
  268. logger.debug("TLS proxy for %s:%s listening on 127.0.0.1:%s", target_host, target_port, _local_port[0])
  269. return _local_port[0], server
  270. def is_chamber_image_model(model: str | None) -> bool:
  271. """Check if printer uses chamber image protocol instead of RTSP.
  272. A1, A1MINI, P1P, P1S use the chamber image protocol on port 6000.
  273. """
  274. return not supports_rtsp(model)
  275. def build_camera_url(ip_address: str, access_code: str, model: str | None) -> str:
  276. """Build the RTSPS URL for the printer camera (RTSP models only)."""
  277. port = get_camera_port(model)
  278. return f"rtsps://bblp:{access_code}@{ip_address}:{port}/streaming/live/1"
  279. def _create_chamber_auth_payload(access_code: str) -> bytes:
  280. """Create the 80-byte authentication payload for chamber image protocol.
  281. Format:
  282. - Bytes 0-3: 0x40 0x00 0x00 0x00 (magic)
  283. - Bytes 4-7: 0x00 0x30 0x00 0x00 (command)
  284. - Bytes 8-15: zeros (padding)
  285. - Bytes 16-47: username "bblp" (32 bytes, null-padded)
  286. - Bytes 48-79: access code (32 bytes, null-padded)
  287. """
  288. username = b"bblp"
  289. access_code_bytes = access_code.encode("utf-8")
  290. # Build the 80-byte payload
  291. payload = struct.pack(
  292. "<II8s32s32s",
  293. 0x40, # Magic header
  294. 0x3000, # Command
  295. b"\x00" * 8, # Padding
  296. username.ljust(32, b"\x00"), # Username padded to 32 bytes
  297. access_code_bytes.ljust(32, b"\x00"), # Access code padded to 32 bytes
  298. )
  299. return payload
  300. def _create_ssl_context() -> ssl.SSLContext:
  301. """Create an SSL context for chamber image connection.
  302. Bambu printers use self-signed certificates, so we disable verification.
  303. """
  304. ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
  305. ctx.check_hostname = False
  306. ctx.verify_mode = ssl.CERT_NONE
  307. return ctx
  308. async def read_chamber_image_frame(
  309. ip_address: str,
  310. access_code: str,
  311. timeout: float = 10.0,
  312. ) -> bytes | None:
  313. """Read a single JPEG frame from the chamber image protocol.
  314. This is used by A1/P1 printers which don't support RTSP.
  315. Args:
  316. ip_address: Printer IP address
  317. access_code: Printer access code
  318. timeout: Connection timeout in seconds
  319. Returns:
  320. JPEG image data or None if failed
  321. """
  322. port = 6000
  323. ssl_context = _create_ssl_context()
  324. try:
  325. # Connect with SSL
  326. reader, writer = await asyncio.wait_for(
  327. asyncio.open_connection(ip_address, port, ssl=ssl_context),
  328. timeout=timeout,
  329. )
  330. try:
  331. # Send authentication payload
  332. auth_payload = _create_chamber_auth_payload(access_code)
  333. writer.write(auth_payload)
  334. await writer.drain()
  335. # Read the 16-byte header
  336. header = await asyncio.wait_for(reader.readexactly(16), timeout=timeout)
  337. if len(header) < 16:
  338. logger.error("Chamber image: incomplete header received")
  339. return None
  340. # Parse payload size from header (little-endian uint32 at offset 0)
  341. payload_size = struct.unpack("<I", header[0:4])[0]
  342. if payload_size == 0 or payload_size > 10_000_000: # Sanity check: max 10MB
  343. logger.error("Chamber image: invalid payload size %s", payload_size)
  344. return None
  345. # Read the JPEG data
  346. jpeg_data = await asyncio.wait_for(
  347. reader.readexactly(payload_size),
  348. timeout=timeout,
  349. )
  350. # Validate JPEG markers
  351. if not jpeg_data.startswith(JPEG_START):
  352. logger.error("Chamber image: data is not a valid JPEG (missing start marker)")
  353. return None
  354. if not jpeg_data.endswith(JPEG_END):
  355. logger.warning("Chamber image: JPEG missing end marker, may be truncated")
  356. logger.debug("Chamber image: received %s bytes", len(jpeg_data))
  357. return jpeg_data
  358. finally:
  359. writer.close()
  360. try:
  361. await writer.wait_closed()
  362. except OSError:
  363. pass # Socket already closed; cleanup is best-effort
  364. except TimeoutError:
  365. logger.error("Chamber image: connection timeout to %s:%s", ip_address, port)
  366. return None
  367. except ConnectionRefusedError:
  368. logger.error("Chamber image: connection refused by %s:%s", ip_address, port)
  369. return None
  370. except Exception as e:
  371. logger.exception("Chamber image: error connecting to %s:%s: %s", ip_address, port, e)
  372. return None
  373. async def generate_chamber_image_stream(
  374. ip_address: str,
  375. access_code: str,
  376. fps: int = 5,
  377. ) -> asyncio.StreamReader | None:
  378. """Create a persistent connection for streaming chamber images.
  379. Returns a connected reader or None if connection failed.
  380. """
  381. port = 6000
  382. ssl_context = _create_ssl_context()
  383. try:
  384. reader, writer = await asyncio.wait_for(
  385. asyncio.open_connection(ip_address, port, ssl=ssl_context),
  386. timeout=10.0,
  387. )
  388. # Send authentication payload
  389. auth_payload = _create_chamber_auth_payload(access_code)
  390. writer.write(auth_payload)
  391. await writer.drain()
  392. logger.info("Chamber image: connected to %s:%s", ip_address, port)
  393. return reader, writer
  394. except Exception as e:
  395. logger.error("Chamber image: failed to connect to %s:%s: %s", ip_address, port, e)
  396. return None
  397. async def read_next_chamber_frame(reader: asyncio.StreamReader, timeout: float = 10.0) -> bytes | None:
  398. """Read the next JPEG frame from an established chamber image connection."""
  399. try:
  400. # Read the 16-byte header
  401. header = await asyncio.wait_for(reader.readexactly(16), timeout=timeout)
  402. # Parse payload size from header (little-endian uint32 at offset 0)
  403. payload_size = struct.unpack("<I", header[0:4])[0]
  404. if payload_size == 0 or payload_size > 10_000_000:
  405. logger.error("Chamber image: invalid payload size %s", payload_size)
  406. return None
  407. # Read the JPEG data
  408. jpeg_data = await asyncio.wait_for(
  409. reader.readexactly(payload_size),
  410. timeout=timeout,
  411. )
  412. return jpeg_data
  413. except asyncio.IncompleteReadError:
  414. logger.warning("Chamber image: connection closed by printer")
  415. return None
  416. except TimeoutError:
  417. logger.warning("Chamber image: read timeout")
  418. return None
  419. except Exception as e:
  420. logger.error("Chamber image: error reading frame: %s", e)
  421. return None
  422. async def capture_camera_frame(
  423. ip_address: str,
  424. access_code: str,
  425. model: str | None,
  426. output_path: Path,
  427. timeout: int = 30,
  428. ) -> bool:
  429. """Capture a single frame from the printer's camera stream and save to disk.
  430. Uses capture_camera_frame_bytes() internally for protocol selection,
  431. then writes the result to the specified output path.
  432. Args:
  433. ip_address: Printer IP address
  434. access_code: Printer access code
  435. model: Printer model (X1, H2D, P1, A1, etc.)
  436. output_path: Path where to save the captured image
  437. timeout: Timeout in seconds for the capture operation
  438. Returns:
  439. True if capture was successful, False otherwise
  440. """
  441. output_path.parent.mkdir(parents=True, exist_ok=True)
  442. jpeg_data = await capture_camera_frame_bytes(ip_address, access_code, model, timeout)
  443. if jpeg_data:
  444. try:
  445. with open(output_path, "wb") as f:
  446. f.write(jpeg_data)
  447. logger.info("Saved camera frame to: %s", output_path)
  448. return True
  449. except OSError as e:
  450. logger.error("Failed to write camera frame: %s", e)
  451. return False
  452. return False
  453. def capture_in_flight(ip_address: str) -> bool:
  454. """Return True iff a one-shot capture for this IP is running right now.
  455. For callers that need to know whether they will JOIN someone else's
  456. capture rather than perform their own — currently only the diagnose tool,
  457. which reports on what it measured and so must not present a coalesced
  458. frame as proof that it opened its own connection (see camera_diagnose).
  459. Ordinary consumers should ignore this: they want "a recent frame", and
  460. capture_camera_frame_bytes() already does the right thing for them.
  461. """
  462. task = _inflight_captures.get(ip_address)
  463. return task is not None and not task.done()
  464. def _discard_inflight_capture(ip_address: str, task: asyncio.Task) -> None:
  465. """Done-callback: drop the finished task from the in-flight registry.
  466. Guarded on identity so a slow task that finishes after a newer capture
  467. has registered can't evict its successor.
  468. Also retrieves the exception, if any. The leader normally awaits the task
  469. and would surface it, but a leader whose own caller was cancelled leaves
  470. nobody to collect it — and an unretrieved task exception is logged by
  471. asyncio as a warning with a traceback at an arbitrary later point.
  472. """
  473. if _inflight_captures.get(ip_address) is task:
  474. del _inflight_captures[ip_address]
  475. if not task.cancelled() and task.exception() is not None:
  476. logger.debug("In-flight camera capture for %s ended in an exception", ip_address)
  477. async def capture_camera_frame_bytes(
  478. ip_address: str,
  479. access_code: str,
  480. model: str | None,
  481. timeout: int = 15,
  482. ) -> bytes | None:
  483. """Capture a single frame and return as JPEG bytes (no disk write).
  484. Concurrent callers for the same printer share one capture (#2705): the
  485. first opens the connection, everyone arriving while it is in flight awaits
  486. the same result. Every consumer here wants "a recent frame" rather than
  487. "a frame captured at exactly my timestamp", so handing identical bytes to
  488. simultaneous callers is correct — and it is the only way to honour the
  489. firmware's one-connection limit without serialising captures behind a lock
  490. (which would just turn a collision into a queue).
  491. This coalesces; it does not cache. A call that arrives after the previous
  492. capture finished always captures fresh. Two consumers of these frames —
  493. plate detection and the finish-photo path — decide things about a running
  494. print from them, and a stale frame there is worse than a slow one: the
  495. whole of #1397 was a finish photo taken seconds late showing the bed
  496. already lowered.
  497. Args:
  498. ip_address: Printer IP address
  499. access_code: Printer access code
  500. model: Printer model (X1, H2D, P1, A1, etc.)
  501. timeout: Timeout in seconds for the capture operation. Applies to this
  502. caller's own wait, including when it joins another caller's
  503. capture — the call sites disagree about the value (10s for plate
  504. detection, 20s for Obico), and a follower must not silently
  505. inherit the leader's deadline in either direction.
  506. Returns:
  507. JPEG bytes if capture was successful, None otherwise
  508. """
  509. # A follower whose leader fails takes a turn of its own rather than
  510. # inheriting a failure it never had a chance to avoid — by then the leader
  511. # has finished, so there is no socket left to compete with. Bounded at two
  512. # rounds: if the capture we joined AND its replacement both failed, a third
  513. # connection won't help, and this caller has already spent its patience.
  514. for _ in range(2):
  515. leader = _inflight_captures.get(ip_address)
  516. if leader is None or leader.done():
  517. break
  518. try:
  519. frame = await asyncio.wait_for(asyncio.shield(leader), timeout=timeout)
  520. except TimeoutError:
  521. # shield() keeps the capture running for whoever else is still
  522. # waiting on it — giving up is this caller's decision alone.
  523. logger.warning(
  524. "Gave up waiting %ss on the in-flight camera capture for %s",
  525. timeout,
  526. ip_address,
  527. )
  528. return None
  529. except asyncio.CancelledError:
  530. # Distinguish "the capture I joined was cancelled" from "I was
  531. # cancelled". Only the former is ours to recover from.
  532. if not leader.cancelled():
  533. raise
  534. logger.info("In-flight camera capture for %s was cancelled; capturing our own", ip_address)
  535. continue
  536. if frame is not None:
  537. logger.info(
  538. "Reusing in-flight camera capture for %s: %s bytes (no second connection opened)",
  539. ip_address,
  540. len(frame),
  541. )
  542. return frame
  543. logger.info("In-flight camera capture for %s failed; capturing our own", ip_address)
  544. else:
  545. return None
  546. task = asyncio.create_task(_capture_camera_frame_bytes_uncoalesced(ip_address, access_code, model, timeout))
  547. _inflight_captures[ip_address] = task
  548. task.add_done_callback(functools.partial(_discard_inflight_capture, ip_address))
  549. # No wait_for here: this caller IS the capture, and the implementation
  550. # already enforces `timeout` internally where it can also kill the ffmpeg
  551. # process. A second deadline on top would abandon the subprocess instead.
  552. # shield() so that a cancelled leader (a client navigating away mid-
  553. # snapshot is routine) doesn't take the capture down with it — the
  554. # followers already waiting on it still get their frame.
  555. return await asyncio.shield(task)
  556. async def _capture_camera_frame_bytes_uncoalesced(
  557. ip_address: str,
  558. access_code: str,
  559. model: str | None,
  560. timeout: int = 15,
  561. ) -> bytes | None:
  562. """Open a connection and capture one frame. See capture_camera_frame_bytes.
  563. Callers want that wrapper, not this: it opens a socket unconditionally,
  564. which is the collision #2705 is about.
  565. """
  566. # Chamber image models: A1/P1 - returns bytes directly
  567. if is_chamber_image_model(model):
  568. logger.info("Capturing camera frame bytes from %s using chamber image protocol (model: %s)", ip_address, model)
  569. return await read_chamber_image_frame(ip_address, access_code, timeout=float(timeout))
  570. # RTSP models: X1/H2/P2 - use ffmpeg piping to stdout
  571. # TLS proxy avoids GnuTLS compatibility issues with some printer firmwares
  572. port = get_camera_port(model)
  573. proxy_port, proxy_server = await create_tls_proxy(ip_address, port)
  574. camera_url = f"rtsp://bblp:{access_code}@127.0.0.1:{proxy_port}/streaming/live/1"
  575. ffmpeg = get_ffmpeg_path()
  576. if not ffmpeg:
  577. proxy_server.close()
  578. await proxy_server.wait_closed()
  579. logger.error("ffmpeg not found for camera frame capture")
  580. return None
  581. cmd = [
  582. ffmpeg,
  583. "-y",
  584. "-rtsp_transport",
  585. "tcp",
  586. "-rtsp_flags",
  587. "prefer_tcp",
  588. "-i",
  589. camera_url,
  590. "-frames:v",
  591. "1",
  592. "-f",
  593. "image2pipe",
  594. "-vcodec",
  595. "mjpeg",
  596. "-q:v",
  597. "2",
  598. "-",
  599. ]
  600. logger.info("Capturing camera frame bytes from %s using RTSP (model: %s)", ip_address, model)
  601. process = None
  602. try:
  603. process = await asyncio.create_subprocess_exec(
  604. *cmd,
  605. stdout=asyncio.subprocess.PIPE,
  606. stderr=asyncio.subprocess.PIPE,
  607. )
  608. _active_capture_pids.add(process.pid)
  609. try:
  610. stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
  611. except TimeoutError:
  612. process.kill()
  613. await process.wait()
  614. logger.error("Camera frame bytes capture timed out after %ss", timeout)
  615. return None
  616. if process.returncode == 0 and stdout and len(stdout) >= 100:
  617. logger.info("Successfully captured camera frame bytes: %s bytes", len(stdout))
  618. return stdout
  619. else:
  620. # ffmpeg echoes the RTSP input URL, which carries the access code.
  621. stderr_text = redact_url_credentials(stderr.decode()) if stderr else "Unknown error"
  622. logger.error("ffmpeg frame bytes capture failed (code %s): %s", process.returncode, stderr_text[:200])
  623. return None
  624. except FileNotFoundError:
  625. logger.error("ffmpeg not found for camera frame capture")
  626. return None
  627. except Exception as e:
  628. logger.exception("Camera frame bytes capture failed: %s", e)
  629. return None
  630. finally:
  631. if process is not None:
  632. _active_capture_pids.discard(process.pid)
  633. proxy_server.close()
  634. await proxy_server.wait_closed()
  635. async def extract_video_last_frame(video_path: Path, output_path: Path) -> bool:
  636. """Extract the last frame of `video_path` as JPEG at `output_path`.
  637. Used to source finish photos from a Bambu timelapse. The Bambu firmware
  638. stops timelapse recording AFTER the toolhead parks but BEFORE the bed-drop
  639. end-gcode runs, so the last frame frames the finished print correctly.
  640. A live camera grab at `gcode_state=FINISH` captures the bed already
  641. lowered (#1397).
  642. Implementation: ``-update 1`` writes each decoded frame to the same
  643. output file (overwriting), so the file left on disk after ffmpeg
  644. finishes is the LAST frame. This works regardless of how short the
  645. video is — a small print's timelapse can be sub-second / sub-30 frames
  646. (one frame per layer-change capture), and the earlier ``-sseof -1.0``
  647. approach failed there because the seek went before the start of the
  648. file and ffmpeg silently returned frame 0 (empty bed at print start).
  649. Decoding every frame is fine: Bambu timelapses are short by
  650. construction (<1 minute even on hours-long prints).
  651. Returns False on missing ffmpeg, missing video, subprocess failure or
  652. timeout. Never raises.
  653. """
  654. ffmpeg = get_ffmpeg_path()
  655. if not ffmpeg:
  656. logger.warning("Cannot extract video last frame: ffmpeg not available")
  657. return False
  658. if not video_path.exists() or video_path.stat().st_size == 0:
  659. logger.warning("Cannot extract last frame: %s missing or empty", video_path)
  660. return False
  661. output_path.parent.mkdir(parents=True, exist_ok=True)
  662. cmd = [
  663. ffmpeg,
  664. "-y",
  665. "-i",
  666. str(video_path),
  667. "-q:v",
  668. "2",
  669. "-update",
  670. "1",
  671. str(output_path),
  672. ]
  673. process = None
  674. try:
  675. process = await asyncio.create_subprocess_exec(
  676. *cmd,
  677. stdout=asyncio.subprocess.PIPE,
  678. stderr=asyncio.subprocess.PIPE,
  679. )
  680. _, stderr = await asyncio.wait_for(process.communicate(), timeout=15.0)
  681. if process.returncode != 0:
  682. logger.warning(
  683. "ffmpeg failed extracting last frame from %s: %s",
  684. video_path,
  685. stderr.decode(errors="replace")[:500],
  686. )
  687. return False
  688. if not output_path.exists() or output_path.stat().st_size == 0:
  689. logger.warning("ffmpeg produced no output for %s", video_path)
  690. return False
  691. return True
  692. except asyncio.TimeoutError:
  693. logger.warning("ffmpeg timed out extracting last frame from %s", video_path)
  694. if process is not None:
  695. try:
  696. process.kill()
  697. await process.wait()
  698. except ProcessLookupError:
  699. pass # Already exited
  700. return False
  701. except OSError as e:
  702. logger.warning("ffmpeg subprocess error for %s: %s", video_path, e)
  703. return False
  704. def apply_camera_rotation(image_data: bytes, rotation: int, logger: logging.Logger) -> bytes:
  705. """Apply a camera_rotation value (degrees clockwise) to a captured JPEG.
  706. Shared by every capture path that saves a still image (notification
  707. snapshots, finish photos, layer-timelapse frames) - previously only
  708. wired into the notification-snapshot path, which left finish photos
  709. and timelapse videos upside-down whenever camera_rotation was set.
  710. Returns *image_data* itself (identity, not a copy) when there is nothing
  711. to do or the rotate fails; callers that write to disk use that to skip a
  712. pointless rewrite.
  713. """
  714. if not rotation:
  715. return image_data
  716. try:
  717. from io import BytesIO
  718. from PIL import Image
  719. img = Image.open(BytesIO(image_data))
  720. # PIL rotate is counter-clockwise, so negate for clockwise rotation
  721. img = img.rotate(-rotation, expand=True)
  722. buf = BytesIO()
  723. img.save(buf, format="JPEG", quality=90)
  724. rotated = buf.getvalue()
  725. # Debug, not info: layer-timelapse calls this once per layer, so a tall
  726. # print would otherwise put hundreds of lines in the log for something
  727. # the surrounding capture already reports at debug level.
  728. logger.debug("Applied %d° camera rotation: %s → %s bytes", rotation, len(image_data), len(rotated))
  729. return rotated
  730. except Exception as e:
  731. logger.warning("Failed to apply camera rotation: %s", e)
  732. return image_data
  733. async def apply_camera_rotation_to_file(path: Path, rotation: int, logger: logging.Logger) -> None:
  734. """Rotate a JPEG that has already been written to disk, in place.
  735. Two finish-photo sources never hold the frame as bytes - ``ffmpeg`` writes
  736. the file for them, and they return only a filename - so they can't use
  737. ``apply_camera_rotation`` directly. Best-effort: any failure leaves the
  738. unrotated file in place, which is what the caller had before.
  739. """
  740. if not rotation:
  741. return
  742. try:
  743. data = await asyncio.to_thread(path.read_bytes)
  744. rotated = await asyncio.to_thread(apply_camera_rotation, data, rotation, logger)
  745. if rotated is data:
  746. # Nothing was done (the rotate failed and returned its input) -
  747. # rewriting the same bytes would only risk truncating a good file.
  748. return
  749. await asyncio.to_thread(path.write_bytes, rotated)
  750. except Exception as e:
  751. logger.warning("Failed to rotate %s in place: %s", path.name, e)
  752. async def capture_finish_photo(
  753. printer_id: int,
  754. ip_address: str,
  755. access_code: str,
  756. model: str | None,
  757. archive_dir: Path,
  758. rotation: int = 0,
  759. ) -> str | None:
  760. """Capture a finish photo and save it to the archive's photos folder.
  761. Args:
  762. printer_id: ID of the printer
  763. ip_address: Printer IP address
  764. access_code: Printer access code
  765. model: Printer model
  766. archive_dir: Directory of the archive (where the 3MF is stored)
  767. rotation: Printer's configured camera_rotation (degrees clockwise).
  768. ffmpeg writes the file directly here, so the rotation is applied
  769. to it afterwards rather than to bytes in hand.
  770. Returns:
  771. Filename of the captured photo, or None if capture failed
  772. """
  773. # Create photos subdirectory
  774. photos_dir = archive_dir / "photos"
  775. photos_dir.mkdir(parents=True, exist_ok=True)
  776. # Generate filename with timestamp
  777. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  778. filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
  779. output_path = (
  780. photos_dir / filename
  781. ) # SEC-PATH-OK: filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg" generated above
  782. success = await capture_camera_frame(
  783. ip_address=ip_address,
  784. access_code=access_code,
  785. model=model,
  786. output_path=output_path,
  787. timeout=30,
  788. )
  789. if success:
  790. await apply_camera_rotation_to_file(output_path, rotation, logger)
  791. logger.info("Finish photo saved: %s", filename)
  792. return filename
  793. else:
  794. logger.warning("Failed to capture finish photo for printer %s", printer_id)
  795. return None
  796. async def test_camera_connection(
  797. ip_address: str,
  798. access_code: str,
  799. model: str | None,
  800. ) -> dict:
  801. """Test if the camera stream is accessible.
  802. Returns dict with success status and any error message.
  803. """
  804. import tempfile
  805. fd, tmp_name = tempfile.mkstemp(suffix=".jpg")
  806. os.close(fd)
  807. test_path = Path(tmp_name)
  808. test_path.chmod(0o600)
  809. try:
  810. success = await capture_camera_frame(
  811. ip_address=ip_address,
  812. access_code=access_code,
  813. model=model,
  814. output_path=test_path,
  815. timeout=15,
  816. )
  817. if success:
  818. return {"success": True, "message": "Camera connection successful"}
  819. else:
  820. return {
  821. "success": False,
  822. "error": (
  823. "Failed to capture frame from camera. "
  824. "Ensure the printer is powered on, camera is enabled, and Developer Mode is active. "
  825. "If running in Docker, try 'network_mode: host' in docker-compose.yml."
  826. ),
  827. }
  828. finally:
  829. # Clean up test file
  830. if test_path.exists():
  831. test_path.unlink()