Procházet zdrojové kódy

Fix external camera not used for snapshot + stream dropping (#325)

The snapshot endpoint always used the internal printer camera even when
an external camera was configured. Now checks for external camera first,
matching the stream endpoint pattern. Also added retry logic (3 attempts,
2s delay) to MJPEG and RTSP stream generators so they reconnect on
timeout instead of silently ending the stream.
maziggy před 3 měsíci
rodič
revize
09d8e7d682

+ 3 - 0
CHANGELOG.md

@@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
 
 
 ## [0.2.0b] - Not released
 ## [0.2.0b] - Not released
 
 
+### Fixed
+- **External Camera Not Used for Snapshot + Stream Dropping** ([#325](https://github.com/maziggy/bambuddy/issues/325)) — The snapshot endpoint (`/camera/snapshot`) always used the internal printer camera even when an external camera was configured. Now checks for external camera first, matching the existing stream endpoint behavior. Also fixed external MJPEG and RTSP streams silently dropping every ~60 seconds due to missing reconnect logic — the underlying stream generators exit on read timeout, and the caller now retries up to 3 times with a 2-second delay instead of ending the stream.
+
 ## [0.1.9] - 2026-02-10
 ## [0.1.9] - 2026-02-10
 
 
 ### New Features
 ### New Features

+ 19 - 0
backend/app/api/routes/camera.py

@@ -547,6 +547,25 @@ async def camera_snapshot(
 
 
     printer = await get_printer_or_404(printer_id, db)
     printer = await get_printer_or_404(printer_id, db)
 
 
+    # Check for external camera first
+    if printer.external_camera_enabled and printer.external_camera_url:
+        from backend.app.services.external_camera import capture_frame
+
+        frame_data = await capture_frame(printer.external_camera_url, printer.external_camera_type, timeout=15)
+        if not frame_data:
+            raise HTTPException(
+                status_code=503,
+                detail="Failed to capture frame from external camera.",
+            )
+        return Response(
+            content=frame_data,
+            media_type="image/jpeg",
+            headers={
+                "Cache-Control": "no-cache, no-store, must-revalidate",
+                "Content-Disposition": f'inline; filename="snapshot_{printer_id}.jpg"',
+            },
+        )
+
     # Create temporary file for the snapshot
     # Create temporary file for the snapshot
     with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
     with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
         temp_path = Path(f.name)
         temp_path = Path(f.name)

+ 33 - 9
backend/app/services/external_camera.py

@@ -487,17 +487,41 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
     last_frame_time = 0.0
     last_frame_time = 0.0
 
 
     if camera_type == "mjpeg":
     if camera_type == "mjpeg":
-        # Proxy MJPEG stream directly
-        async for frame in _stream_mjpeg(url):
-            current_time = asyncio.get_event_loop().time()
-            if current_time - last_frame_time >= frame_interval:
-                last_frame_time = current_time
-                yield _format_mjpeg_frame(frame)
+        # Proxy MJPEG stream directly, with reconnect on timeout
+        max_retries = 3
+        for attempt in range(max_retries + 1):
+            frame_yielded = False
+            async for frame in _stream_mjpeg(url):
+                frame_yielded = True
+                current_time = asyncio.get_event_loop().time()
+                if current_time - last_frame_time >= frame_interval:
+                    last_frame_time = current_time
+                    yield _format_mjpeg_frame(frame)
+            if not frame_yielded or attempt == max_retries:
+                break
+            logger.warning(
+                "External MJPEG stream ended, reconnecting (attempt %d/%d)...",
+                attempt + 1,
+                max_retries,
+            )
+            await asyncio.sleep(2)
 
 
     elif camera_type == "rtsp":
     elif camera_type == "rtsp":
-        # Use ffmpeg to convert RTSP to MJPEG
-        async for frame in _stream_rtsp(url, fps):
-            yield _format_mjpeg_frame(frame)
+        # Use ffmpeg to convert RTSP to MJPEG, with reconnect on timeout
+        max_retries = 3
+        for attempt in range(max_retries + 1):
+            frame_yielded = False
+            async for frame in _stream_rtsp(url, fps):
+                frame_yielded = True
+                yield _format_mjpeg_frame(frame)
+            if not frame_yielded or attempt == max_retries:
+                break
+            logger.warning(
+                "External RTSP stream ended, reconnecting (attempt %d/%d)...",
+                attempt + 1,
+                max_retries,
+            )
+            await asyncio.sleep(2)
 
 
     elif camera_type == "usb":
     elif camera_type == "usb":
         # Use ffmpeg to stream from USB camera
         # Use ffmpeg to stream from USB camera

+ 43 - 0
backend/tests/integration/test_camera_api.py

@@ -192,6 +192,49 @@ class TestCameraAPI:
         assert response.status_code == 503
         assert response.status_code == 503
         assert "Failed to capture" in response.json()["detail"]
         assert "Failed to capture" in response.json()["detail"]
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_camera_snapshot_external_camera_success(self, async_client: AsyncClient, printer_factory):
+        """Verify snapshot uses external camera when configured."""
+        printer = await printer_factory(
+            external_camera_enabled=True,
+            external_camera_url="http://192.168.1.50/mjpeg",
+            external_camera_type="mjpeg",
+        )
+
+        fake_jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
+
+        with patch(
+            "backend.app.services.external_camera.capture_frame",
+            new_callable=AsyncMock,
+            return_value=fake_jpeg,
+        ):
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
+
+        assert response.status_code == 200
+        assert response.headers["content-type"] == "image/jpeg"
+        assert response.content == fake_jpeg
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_camera_snapshot_external_camera_failure(self, async_client: AsyncClient, printer_factory):
+        """Verify 503 when external camera capture fails."""
+        printer = await printer_factory(
+            external_camera_enabled=True,
+            external_camera_url="http://192.168.1.50/mjpeg",
+            external_camera_type="mjpeg",
+        )
+
+        with patch(
+            "backend.app.services.external_camera.capture_frame",
+            new_callable=AsyncMock,
+            return_value=None,
+        ):
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
+
+        assert response.status_code == 503
+        assert "external camera" in response.json()["detail"].lower()
+
     # ========================================================================
     # ========================================================================
     # Camera Stream Endpoint
     # Camera Stream Endpoint
     # ========================================================================
     # ========================================================================