Browse Source

fix(camera): plate-detection UI now uses the external camera when configured (#1359)

  Reporter @Andlar94 hit a permanent "Build plate not empty" on every print
  start on an A1 with an external RTSP camera. The runtime auto-check at
  main.py:1819 called check_plate_empty with use_external=external_camera_enabled,
  but the manual UI routes (camera.py) declared use_external: bool = False
  and the frontend client always sent use_external=false. So calibration
  captured a built-in frame and stamped it as the reference; the runtime
  check captured an external frame and diffed it against that reference --
  a permanent mismatch.

  Centralise the default on the backend: both routes now take bool | None,
  deriving the default from the printer's external_camera_enabled +
  external_camera_url + external_camera_type. The frontend client stops
  sending the flag unless the caller explicitly sets it, so the existing
  UI call sites immediately benefit and any future caller gets the right
  camera automatically. Explicit overrides still win.

  Adds 4 regression tests pinning the new default for both the
  external-enabled and external-disabled cases, plus the explicit override
  path so a future "always built-in" caller stays supported.
maziggy 1 week ago
parent
commit
29379e3be7

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 21 - 4
backend/app/api/routes/camera.py

@@ -1001,7 +1001,7 @@ async def test_external_camera(
 async def check_plate_empty(
 async def check_plate_empty(
     printer_id: int,
     printer_id: int,
     plate_type: str | None = None,
     plate_type: str | None = None,
-    use_external: bool = False,
+    use_external: bool | None = None,
     include_debug_image: bool = False,
     include_debug_image: bool = False,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
@@ -1016,7 +1016,11 @@ async def check_plate_empty(
     Args:
     Args:
         printer_id: Printer ID
         printer_id: Printer ID
         plate_type: Type of build plate (e.g., "High Temp Plate") for calibration lookup
         plate_type: Type of build plate (e.g., "High Temp Plate") for calibration lookup
-        use_external: If True, prefer external camera over built-in
+        use_external: If True, prefer external camera over built-in. When omitted
+            (None), defaults to the printer's external_camera_enabled setting —
+            mirroring the runtime auto-check at print start (main.py). Without
+            this default the UI's manual check would always use the built-in
+            camera, mismatching the reference saved during calibration (#1359).
         include_debug_image: If True, return URL to annotated debug image
         include_debug_image: If True, return URL to annotated debug image
 
 
     Returns:
     Returns:
@@ -1037,6 +1041,11 @@ async def check_plate_empty(
     # Check printer exists first (before OpenCV check)
     # Check printer exists first (before OpenCV check)
     printer = await get_printer_or_404(printer_id, db)
     printer = await get_printer_or_404(printer_id, db)
 
 
+    if use_external is None:
+        use_external = bool(
+            printer.external_camera_enabled and printer.external_camera_url and printer.external_camera_type
+        )
+
     if not is_plate_detection_available():
     if not is_plate_detection_available():
         raise HTTPException(
         raise HTTPException(
             status_code=503,
             status_code=503,
@@ -1111,7 +1120,7 @@ async def check_plate_empty(
 async def calibrate_plate_detection(
 async def calibrate_plate_detection(
     printer_id: int,
     printer_id: int,
     label: str | None = None,
     label: str | None = None,
-    use_external: bool = False,
+    use_external: bool | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
@@ -1128,7 +1137,10 @@ async def calibrate_plate_detection(
     Args:
     Args:
         printer_id: Printer ID
         printer_id: Printer ID
         label: Optional label for this reference (e.g., "High Temp Plate", "Wham Bam")
         label: Optional label for this reference (e.g., "High Temp Plate", "Wham Bam")
-        use_external: If True, prefer external camera over built-in
+        use_external: If True, prefer external camera over built-in. When omitted
+            (None), defaults to the printer's external_camera_enabled setting so
+            calibration captures from the same source the runtime auto-check
+            uses at print start (#1359).
 
 
     Returns:
     Returns:
         Dict with:
         Dict with:
@@ -1145,6 +1157,11 @@ async def calibrate_plate_detection(
     # Check printer exists first (before OpenCV check)
     # Check printer exists first (before OpenCV check)
     printer = await get_printer_or_404(printer_id, db)
     printer = await get_printer_or_404(printer_id, db)
 
 
+    if use_external is None:
+        use_external = bool(
+            printer.external_camera_enabled and printer.external_camera_url and printer.external_camera_type
+        )
+
     if not is_plate_detection_available():
     if not is_plate_detection_available():
         raise HTTPException(
         raise HTTPException(
             status_code=503,
             status_code=503,

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

@@ -459,6 +459,138 @@ class TestCameraAPI:
         assert result["success"] is True
         assert result["success"] is True
         assert "index" in result
         assert "index" in result
 
 
+    # ------------------------------------------------------------------
+    # Regression: #1359 — the manual UI check/calibrate routes must derive
+    # use_external from the printer's external_camera_enabled setting when
+    # the caller omits the flag. Otherwise the UI calibrates against the
+    # built-in camera while the runtime auto-check at print start uses the
+    # external one, producing a permanent "build plate not empty".
+    # ------------------------------------------------------------------
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_check_plate_defaults_use_external_when_external_camera_enabled(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        """Omitting use_external on a printer with external camera enabled
+        must call the service with use_external=True."""
+        printer = await printer_factory(
+            external_camera_enabled=True,
+            external_camera_url="http://192.168.1.50/mjpeg",
+            external_camera_type="mjpeg",
+        )
+
+        mock_result = MagicMock()
+        mock_result.to_dict.return_value = {
+            "is_empty": True,
+            "confidence": 0.95,
+            "difference_percent": 0.5,
+            "message": "Plate appears empty",
+            "has_debug_image": False,
+            "needs_calibration": False,
+        }
+        mock_result.debug_image = None
+
+        mock_detector = MagicMock()
+        mock_detector.get_calibration_count.return_value = 0
+        mock_detector.MAX_REFERENCES = 5
+
+        with (
+            patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
+            patch("backend.app.services.plate_detection.check_plate_empty", new_callable=AsyncMock) as mock_check,
+            patch("backend.app.services.plate_detection.PlateDetector", return_value=mock_detector),
+        ):
+            mock_check.return_value = mock_result
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/check-plate")
+
+        assert response.status_code == 200
+        assert mock_check.await_args.kwargs["use_external"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_check_plate_defaults_use_external_false_when_external_camera_disabled(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        """Omitting use_external on a printer without an external camera
+        must call the service with use_external=False (built-in)."""
+        printer = await printer_factory()  # external_camera_enabled defaults to False
+
+        mock_result = MagicMock()
+        mock_result.to_dict.return_value = {
+            "is_empty": True,
+            "confidence": 0.95,
+            "difference_percent": 0.5,
+            "message": "Plate appears empty",
+            "has_debug_image": False,
+            "needs_calibration": False,
+        }
+        mock_result.debug_image = None
+
+        mock_detector = MagicMock()
+        mock_detector.get_calibration_count.return_value = 0
+        mock_detector.MAX_REFERENCES = 5
+
+        with (
+            patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
+            patch("backend.app.services.plate_detection.check_plate_empty", new_callable=AsyncMock) as mock_check,
+            patch("backend.app.services.plate_detection.PlateDetector", return_value=mock_detector),
+        ):
+            mock_check.return_value = mock_result
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/check-plate")
+
+        assert response.status_code == 200
+        assert mock_check.await_args.kwargs["use_external"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_calibrate_plate_defaults_use_external_when_external_camera_enabled(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        """Calibrating with use_external omitted on an external-camera-enabled
+        printer captures the reference from the external camera — matching
+        what the runtime check at print start will compare against (#1359)."""
+        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.plate_detection.is_plate_detection_available", return_value=True),
+            patch("backend.app.services.plate_detection.calibrate_plate", new_callable=AsyncMock) as mock_calibrate,
+        ):
+            mock_calibrate.return_value = (True, "Calibration saved (1/5 references)", 0)
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate")
+
+        assert response.status_code == 200
+        assert mock_calibrate.await_args.kwargs["use_external"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_calibrate_plate_explicit_use_external_false_overrides_default(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        """An explicit use_external=false from the caller still wins even
+        when the printer has an external camera configured, so power users
+        can force a built-in-camera reference if they ever need to."""
+        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.plate_detection.is_plate_detection_available", return_value=True),
+            patch("backend.app.services.plate_detection.calibrate_plate", new_callable=AsyncMock) as mock_calibrate,
+        ):
+            mock_calibrate.return_value = (True, "Calibration saved (1/5 references)", 0)
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate?use_external=false"
+            )
+
+        assert response.status_code == 200
+        assert mock_calibrate.await_args.kwargs["use_external"] is False
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_delete_calibration_printer_not_found(self, async_client: AsyncClient):
     async def test_delete_calibration_printer_not_found(self, async_client: AsyncClient):

+ 9 - 2
frontend/src/api/client.ts

@@ -4914,7 +4914,12 @@ export const api = {
   // Plate Detection - Multi-reference calibration (stores up to 5 references per printer)
   // Plate Detection - Multi-reference calibration (stores up to 5 references per printer)
   checkPlateEmpty: (printerId: number, options?: { useExternal?: boolean; includeDebugImage?: boolean }) => {
   checkPlateEmpty: (printerId: number, options?: { useExternal?: boolean; includeDebugImage?: boolean }) => {
     const params = new URLSearchParams();
     const params = new URLSearchParams();
-    params.set('use_external', String(options?.useExternal ?? false));
+    // Only forward use_external when the caller explicitly sets it. Omitted →
+    // backend derives the default from the printer's external_camera_enabled
+    // setting so calibration and runtime checks use the same camera (#1359).
+    if (options?.useExternal !== undefined) {
+      params.set('use_external', String(options.useExternal));
+    }
     params.set('include_debug_image', String(options?.includeDebugImage ?? false));
     params.set('include_debug_image', String(options?.includeDebugImage ?? false));
     return request<PlateDetectionResult>(
     return request<PlateDetectionResult>(
       `/printers/${printerId}/camera/check-plate?${params.toString()}`
       `/printers/${printerId}/camera/check-plate?${params.toString()}`
@@ -4928,7 +4933,9 @@ export const api = {
   calibratePlateDetection: (printerId: number, options?: { label?: string; useExternal?: boolean }) => {
   calibratePlateDetection: (printerId: number, options?: { label?: string; useExternal?: boolean }) => {
     const params = new URLSearchParams();
     const params = new URLSearchParams();
     if (options?.label) params.set('label', options.label);
     if (options?.label) params.set('label', options.label);
-    params.set('use_external', String(options?.useExternal ?? false));
+    if (options?.useExternal !== undefined) {
+      params.set('use_external', String(options.useExternal));
+    }
     return request<CalibrationResult & { index: number }>(
     return request<CalibrationResult & { index: number }>(
       `/printers/${printerId}/camera/plate-detection/calibrate?${params.toString()}`,
       `/printers/${printerId}/camera/plate-detection/calibrate?${params.toString()}`,
       { method: 'POST' }
       { method: 'POST' }

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-lshkWq_8.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BlvVqj3j.js"></script>
+    <script type="module" crossorigin src="/assets/index-lshkWq_8.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Baw5c3Hn.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Baw5c3Hn.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff