failure_detection_camera.py 3.1 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
  1. """Shared camera capture for AI failure detection providers."""
  2. import logging
  3. from backend.app.core.database import async_session
  4. from backend.app.models.printer import Printer
  5. logger = logging.getLogger(__name__)
  6. async def capture_detection_frame(printer_id: int, *, session_factory=async_session, timeout: int = 20) -> bytes | None:
  7. """Capture a JPEG, sharing live camera buffers to avoid competing readers."""
  8. # Late import to avoid cycles at module load time
  9. from backend.app.services.camera import capture_camera_frame_bytes
  10. from backend.app.services.external_camera import capture_frame as capture_external_frame
  11. async with session_factory() as db:
  12. printer = await db.get(Printer, printer_id)
  13. if printer is None:
  14. raise ValueError(f"Printer {printer_id} not found")
  15. if printer.external_camera_enabled and printer.external_camera_url:
  16. # Same rule as the built-in branch below, which this used to skip:
  17. # an external camera is single-reader too, so polling while a viewer
  18. # is attached just fails (#2707).
  19. from backend.app.api.routes.camera import live_frame_for_capture
  20. defer, buffered = live_frame_for_capture(printer_id)
  21. if defer:
  22. if buffered:
  23. return buffered
  24. logger.info(
  25. "AI failure detection: viewer attached for printer %s but buffer empty; "
  26. "skipping this poll to avoid competing camera handle (#2707)",
  27. printer_id,
  28. )
  29. return None
  30. return await capture_external_frame(
  31. printer.external_camera_url,
  32. printer.external_camera_type,
  33. timeout=timeout,
  34. snapshot_url=printer.external_camera_snapshot_url,
  35. )
  36. # Reuse the fan-out broadcaster's buffered frame when a viewer is
  37. # already watching — avoids opening a second concurrent RTSP socket
  38. # on printers that allow only one camera connection (e.g. X2D
  39. # firmware 01.01.00.00; see #1271). Buffered frame is <1s old while
  40. # a viewer is connected.
  41. #
  42. # When a viewer is attached but no frame is buffered yet (startup
  43. # race, mid-reconnect), we DELIBERATELY skip this poll cycle instead
  44. # of falling through to capture_camera_frame_bytes. Opening a fresh
  45. # RTSP/chamber socket would compete with the live viewer and kick
  46. # the fan-out connection on most firmwares — exactly the freeze
  47. # reported in #1348. The poll loop retries in ~10s.
  48. from backend.app.api.routes.camera import is_stream_active, try_get_active_buffered_frame
  49. if is_stream_active(printer_id):
  50. buffered = try_get_active_buffered_frame(printer_id)
  51. if buffered:
  52. return buffered
  53. logger.info(
  54. "AI failure detection: viewer attached for printer %s but buffer empty; skipping this poll to avoid competing camera socket (#1348)",
  55. printer_id,
  56. )
  57. return None
  58. return await capture_camera_frame_bytes(
  59. ip_address=printer.ip_address,
  60. access_code=printer.access_code,
  61. model=printer.model,
  62. timeout=timeout,
  63. )