Просмотр исходного кода

fix(camera): the plate check holds the chamber light like other camera uses, so a live view opened meanwhile keeps it; captures use the live frame of a watched single-client external camera; a reconnecting X1C live view is not ended by the cleanup; camera test/diagnose/plate routes release their DB connection before capturing

maziggy 2 дней назад
Родитель
Сommit
cf0ba31f19

+ 4 - 0
CHANGELOG.md

@@ -164,6 +164,10 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 
 ### Fixed
 ### Fixed
+- **Camera light: the plate check could switch the light off under a live view** — With the camera light setting on, a live view opened while the print-start plate check had the chamber light on found it already lit, so it never took it, and the check then switched it off: the view went dark. The plate check now holds the light like every other camera use (and still lights the chamber whatever the camera light setting), so it goes off only once nothing uses the camera, after the usual short grace.
+- **External MJPEG cameras: timelapse frames, finish photos and Obico checks failed while someone watched** — A single-client camera (an ESP32 stream, for example) refused the second connection these opened while a live view was running. They now take the live view's frame, as they already did for the printer's own camera. A camera with a separate snapshot URL keeps using that URL.
+- **X1C live view could stop instead of reconnecting** — The printer ends each camera session about once a minute and Bambuddy redials; a background cleanup that ran at that moment could make the live view end instead.
+- **Camera test, diagnose, plate check and plate calibration held a database connection for the whole capture** — Up to 30 seconds each, which on a large farm could exhaust the connection pool. They now release it before capturing.
 - **API: `GET /queue?status=` with several statuses returned nothing** — The documented `?status=pending,printing` was matched as one status. Comma-separated statuses are now each matched.
 - **API: `GET /queue?status=` with several statuses returned nothing** — The documented `?status=pending,printing` was matched as one status. Comma-separated statuses are now each matched.
 - **Saving an edited queue item showed "Queue item updated" twice** — The dialog also closed twice, a failed save showed two error toasts, and an edit that also added the job for further printers closed the dialog before those were added. It now reports and closes once, after everything is saved.
 - **Saving an edited queue item showed "Queue item updated" twice** — The dialog also closed twice, a failed save showed two error toasts, and an edit that also added the job for further printers closed the dialog before those were added. It now reports and closes once, after everything is saved.
 - **A failed Unassign on the printer card said nothing** — Unassigning a spool from an AMS slot (built-in inventory or Spoolman) that the server refused showed no message, and the spool simply stayed assigned. The error is now shown.
 - **A failed Unassign on the printer card said nothing** — Unassigning a spool from an AMS slot (built-in inventory or Spoolman) that the server refused showed no message, and the spool simply stayed assigned. The error is now shown.

+ 52 - 13
backend/app/api/routes/camera.py

@@ -126,6 +126,13 @@ _stream_start_times: dict[int, float] = {}
 # the others while theirs still run.
 # the others while theirs still run.
 _active_external_streams: dict[int, int] = {}
 _active_external_streams: dict[int, int] = {}
 
 
+# The same viewers, counted only for an external camera with no snapshot URL:
+# its live view is then the camera's one reader, and a one-shot capture must
+# use its frame (SP-C7). An MJPEG or snapshot camera runs no process the
+# stream registry sees. A camera with a snapshot URL serves captures from that
+# URL beside the live view, at full quality (#1177).
+_external_live_readers: dict[int, int] = {}
+
 # Track ALL spawned ffmpeg PIDs (persists even if _active_streams entries are removed)
 # Track ALL spawned ffmpeg PIDs (persists even if _active_streams entries are removed)
 # Maps PID -> spawn timestamp — used by cleanup to find truly orphaned OS processes
 # Maps PID -> spawn timestamp — used by cleanup to find truly orphaned OS processes
 _spawned_ffmpeg_pids: dict[int, float] = {}
 _spawned_ffmpeg_pids: dict[int, float] = {}
@@ -180,6 +187,11 @@ def is_stream_active(printer_id: int) -> bool:
     returns None (the stream may be running but the first frame hasn't landed
     returns None (the stream may be running but the first frame hasn't landed
     in the buffer yet, or the upstream is mid-reconnect).
     in the buffer yet, or the upstream is mid-reconnect).
     """
     """
+    return _stream_registered(printer_id) or printer_id in _external_live_readers
+
+
+def _stream_registered(printer_id: int) -> bool:
+    """A stream of this printer is in the process or chamber registry."""
     return any(k.startswith(f"{printer_id}-") for k in _active_streams) or any(
     return any(k.startswith(f"{printer_id}-") for k in _active_streams) or any(
         k.startswith(f"{printer_id}-") for k in _active_chamber_streams
         k.startswith(f"{printer_id}-") for k in _active_chamber_streams
     )
     )
@@ -362,9 +374,11 @@ def _release_printer_frame_state(printer_id: int | None) -> None:
     orphan, and snapshots without a frame to reuse.
     orphan, and snapshots without a frame to reuse.
 
 
     Call this AFTER removing the departing stream's own key, so the check
     Call this AFTER removing the departing stream's own key, so the check
-    reports on other streams rather than on the caller.
+    reports on other streams rather than on the caller. The external viewer
+    count is not consulted: an external upstream ends before its viewers are
+    told, and its frame must not outlive it.
     """
     """
-    if printer_id is None or is_stream_active(printer_id):
+    if printer_id is None or _stream_registered(printer_id):
         return
         return
     _last_frames.pop(printer_id, None)
     _last_frames.pop(printer_id, None)
     _last_frame_times.pop(printer_id, None)
     _last_frame_times.pop(printer_id, None)
@@ -1276,6 +1290,8 @@ async def camera_stream(
             except Exception:
             except Exception:
                 return True
                 return True
 
 
+        single_reader = not printer.external_camera_snapshot_url
+
         def _log_external_detach(remaining: int) -> None:
         def _log_external_detach(remaining: int) -> None:
             logger.info("Camera viewer detached from %s (subscribers=%d)", fanout_key, remaining)
             logger.info("Camera viewer detached from %s (subscribers=%d)", fanout_key, remaining)
 
 
@@ -1296,6 +1312,8 @@ async def camera_stream(
                     broadcaster = await get_or_create_broadcaster(fanout_key, _external_factory)
                     broadcaster = await get_or_create_broadcaster(fanout_key, _external_factory)
                     queue = await broadcaster.subscribe()
                     queue = await broadcaster.subscribe()
                 _active_external_streams[printer_id] = _active_external_streams.get(printer_id, 0) + 1
                 _active_external_streams[printer_id] = _active_external_streams.get(printer_id, 0) + 1
+                if single_reader:
+                    _external_live_readers[printer_id] = _external_live_readers.get(printer_id, 0) + 1
                 # When the shared upstream first started (setdefault), as for the
                 # When the shared upstream first started (setdefault), as for the
                 # printer's own camera; its end clears it.
                 # printer's own camera; its end clears it.
                 _stream_start_times.setdefault(printer_id, time.time())
                 _stream_start_times.setdefault(printer_id, time.time())
@@ -1319,6 +1337,12 @@ async def camera_stream(
                         _active_external_streams[printer_id] = remaining
                         _active_external_streams[printer_id] = remaining
                     else:
                     else:
                         _active_external_streams.pop(printer_id, None)
                         _active_external_streams.pop(printer_id, None)
+                    if single_reader:
+                        readers = _external_live_readers.get(printer_id, 0) - 1
+                        if readers > 0:
+                            _external_live_readers[printer_id] = readers
+                        else:
+                            _external_live_readers.pop(printer_id, None)
                     logger.info("External camera viewer left printer %s", printer_id)
                     logger.info("External camera viewer left printer %s", printer_id)
 
 
         return StreamingResponse(
         return StreamingResponse(
@@ -1483,6 +1507,7 @@ async def forget_printer(printer_id: int) -> None:
     _last_frame_times.pop(printer_id, None)
     _last_frame_times.pop(printer_id, None)
     _stream_start_times.pop(printer_id, None)
     _stream_start_times.pop(printer_id, None)
     _active_external_streams.pop(printer_id, None)
     _active_external_streams.pop(printer_id, None)
+    _external_live_readers.pop(printer_id, None)
 
 
 
 
 async def _stop_printer_streams(printer_id: int) -> int:
 async def _stop_printer_streams(printer_id: int) -> int:
@@ -1668,14 +1693,15 @@ async def _snapshot_response(printer_id: int, printer: Printer) -> Response:
 @router.get("/{printer_id}/camera/test")
 @router.get("/{printer_id}/camera/test")
 async def test_camera(
 async def test_camera(
     printer_id: int,
     printer_id: int,
-    db: AsyncSession = Depends(get_db),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Test camera connection for a printer.
     """Test camera connection for a printer.
 
 
     Returns success status and any error message.
     Returns success status and any error message.
     """
     """
-    printer = await get_printer_or_404(printer_id, db)
+    # A short session, released before the capture (RC-C10, as #2572).
+    async with database.async_session() as db:
+        printer = await get_printer_or_404(printer_id, db)
 
 
     result = await test_camera_connection(
     result = await test_camera_connection(
         ip_address=printer.ip_address,
         ip_address=printer.ip_address,
@@ -1689,7 +1715,6 @@ async def test_camera(
 @router.post("/{printer_id}/camera/diagnose")
 @router.post("/{printer_id}/camera/diagnose")
 async def diagnose_camera_route(
 async def diagnose_camera_route(
     printer_id: int,
     printer_id: int,
-    db: AsyncSession = Depends(get_db),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Run staged diagnostics for a printer's camera path.
     """Run staged diagnostics for a printer's camera path.
@@ -1702,7 +1727,9 @@ async def diagnose_camera_route(
 
 
     from backend.app.services.camera_diagnose import diagnose_camera
     from backend.app.services.camera_diagnose import diagnose_camera
 
 
-    printer = await get_printer_or_404(printer_id, db)
+    # A short session, released before the capture (RC-C10, as #2572).
+    async with database.async_session() as db:
+        printer = await get_printer_or_404(printer_id, db)
 
 
     # Look up live-stream evidence so the diagnostic can short-circuit
     # Look up live-stream evidence so the diagnostic can short-circuit
     # instead of fighting a viewer for the printer's single camera slot.
     # instead of fighting a viewer for the printer's single camera slot.
@@ -1797,7 +1824,6 @@ async def test_external_camera(
     body: ExternalCameraTestRequest | None = Body(default=None),
     body: ExternalCameraTestRequest | None = Body(default=None),
     url: str | None = None,
     url: str | None = None,
     camera_type: str | None = None,
     camera_type: str | None = None,
-    db: AsyncSession = Depends(get_db),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Test external camera connection.
     """Test external camera connection.
@@ -1820,7 +1846,9 @@ async def test_external_camera(
         raise HTTPException(status_code=422, detail="url and camera_type are required")
         raise HTTPException(status_code=422, detail="url and camera_type are required")
 
 
     # Verify printer exists (for authorization)
     # Verify printer exists (for authorization)
-    printer = await get_printer_or_404(printer_id, db)
+    # A short session, released before the capture (RC-C10, as #2572).
+    async with database.async_session() as db:
+        printer = await get_printer_or_404(printer_id, db)
 
 
     from backend.app.services.external_camera import jpeg_resolution, test_connection
     from backend.app.services.external_camera import jpeg_resolution, test_connection
 
 
@@ -1858,7 +1886,6 @@ async def check_plate_empty(
     plate_type: str | None = None,
     plate_type: str | None = None,
     use_external: bool | None = None,
     use_external: bool | None = None,
     include_debug_image: bool = False,
     include_debug_image: bool = False,
-    db: AsyncSession = Depends(get_db),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Check if the build plate is empty using camera vision.
     """Check if the build plate is empty using camera vision.
@@ -1895,7 +1922,9 @@ async def check_plate_empty(
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.printer_manager import printer_manager
 
 
     # Check printer exists first (before OpenCV check)
     # Check printer exists first (before OpenCV check)
-    printer = await get_printer_or_404(printer_id, db)
+    # A short session, released before the capture (RC-C10, as #2572).
+    async with database.async_session() as db:
+        printer = await get_printer_or_404(printer_id, db)
 
 
     if use_external is None:
     if use_external is None:
         use_external = bool(
         use_external = bool(
@@ -1977,7 +2006,6 @@ async def calibrate_plate_detection(
     printer_id: int,
     printer_id: int,
     label: str | None = None,
     label: str | None = None,
     use_external: bool | None = None,
     use_external: bool | None = None,
-    db: AsyncSession = Depends(get_db),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
 ):
 ):
     """Calibrate plate detection by capturing a reference image of the empty plate.
     """Calibrate plate detection by capturing a reference image of the empty plate.
@@ -2011,7 +2039,9 @@ async def calibrate_plate_detection(
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.printer_manager import printer_manager
 
 
     # Check printer exists first (before OpenCV check)
     # Check printer exists first (before OpenCV check)
-    printer = await get_printer_or_404(printer_id, db)
+    # A short session, released before the capture (RC-C10, as #2572).
+    async with database.async_session() as db:
+        printer = await get_printer_or_404(printer_id, db)
 
 
     if use_external is None:
     if use_external is None:
         use_external = bool(
         use_external = bool(
@@ -2326,7 +2356,16 @@ async def cleanup_orphaned_streams():
     # 3. Clean up _active_streams entries with dead processes. Only these two: a
     # 3. Clean up _active_streams entries with dead processes. Only these two: a
     # reconnecting RTSP stream starts its next ffmpeg under the same stream id,
     # reconnecting RTSP stream starts its next ffmpeg under the same stream id,
     # and its disconnect event and viewer must still reach it.
     # and its disconnect event and viewer must still reach it.
-    dead_streams = [sid for sid, proc in _active_streams.items() if proc.returncode is not None]
+    # Not one whose generator is still running and was not told to stop: its
+    # ffmpeg ended with the session (an X1C ends one about every minute) and it
+    # is tearing down to redial. Removing the entry then ended the live view,
+    # as the generator reads a missing entry as a stop (RC-C13). Every stop
+    # sets the event first.
+    dead_streams = [
+        sid
+        for sid, proc in _active_streams.items()
+        if proc.returncode is not None and not (sid in _disconnect_events and not _disconnect_events[sid].is_set())
+    ]
     for sid in dead_streams:
     for sid in dead_streams:
         proc = _active_streams.pop(sid, None)
         proc = _active_streams.pop(sid, None)
         if proc:
         if proc:

+ 4 - 25
backend/app/main.py

@@ -4543,21 +4543,13 @@ async def on_print_start(printer_id: int, data: dict):
                         printer.plate_detection_roi_h,
                         printer.plate_detection_roi_h,
                     )
                     )
 
 
-                # Auto-turn on chamber light if it's off for better detection
-                light_was_off = False
-                client = printer_manager.get_client(printer_id)
-                if client and client.state:
-                    light_was_off = not client.state.chamber_light
-                    if light_was_off:
-                        logger.info("[PLATE CHECK] Turning on chamber light for printer %s", printer_id)
-                        client.set_chamber_light(True)
-                        # Wait for light to physically turn on and camera to adjust exposure
-                        await asyncio.sleep(2.5)
-
+                # The light is held, not switched, so a live view opened
+                # meanwhile keeps it when the check lets go (SP-C11). Every
+                # printer, whatever the camera light mode: detection needs it.
                 plate_photo_data = None
                 plate_photo_data = None
                 objects_detected = False
                 objects_detected = False
                 check_failure: str | None = None
                 check_failure: str | None = None
-                try:
+                async with camera_light(printer, any_mode=True, settle=2.5):
                     logger.info("[PLATE CHECK] Running plate detection for printer %s", printer_id)
                     logger.info("[PLATE CHECK] Running plate detection for printer %s", printer_id)
                     plate_result = await check_plate_empty(
                     plate_result = await check_plate_empty(
                         printer_id=printer_id,
                         printer_id=printer_id,
@@ -4597,19 +4589,6 @@ async def on_print_start(printer_id: int, data: dict):
                             logger.warning(
                             logger.warning(
                                 "[PLATE CHECK] Failed to capture snapshot for printer %s: %s", printer_id, snap_err
                                 "[PLATE CHECK] Failed to capture snapshot for printer %s: %s", printer_id, snap_err
                             )
                             )
-                finally:
-                    # Restore chamber light to original state as soon as the
-                    # camera is done with it, whatever happened above.
-                    if light_was_off and client:
-                        logger.info("[PLATE CHECK] Restoring chamber light to off for printer %s", printer_id)
-                        try:
-                            client.set_chamber_light(False)
-                        except Exception as light_err:
-                            logger.warning(
-                                "[PLATE CHECK] Failed to restore chamber light for printer %s: %s",
-                                printer_id,
-                                light_err,
-                            )
 
 
                 if objects_detected:
                 if objects_detected:
                     # Send notification about plate not empty
                     # Send notification about plate not empty

+ 13 - 2
backend/app/services/camera_light.py

@@ -167,18 +167,29 @@ def hand_back(printer_id: int) -> None:
 
 
 
 
 @asynccontextmanager
 @asynccontextmanager
-async def camera_light(printer, *, wait: bool = True) -> AsyncIterator[None]:
+async def camera_light(
+    printer, *, wait: bool = True, any_mode: bool = False, settle: float | None = None
+) -> AsyncIterator[None]:
     """Keep the printer's chamber light on while the camera is in use.
     """Keep the printer's chamber light on while the camera is in use.
 
 
     ``wait`` makes a snapshot wait the ``camera_light_delay`` setting after
     ``wait`` makes a snapshot wait the ``camera_light_delay`` setting after
     the light came on, so the light (and any light synced to it) has time to
     the light came on, so the light (and any light synced to it) has time to
     reach the picture. A live view doesn't wait.
     reach the picture. A live view doesn't wait.
+
+    ``any_mode`` holds the light whatever the light mode says, and ``settle``
+    replaces the delay setting: the plate check needs a lit plate on every
+    printer, and holding the light here rather than switching it itself keeps
+    it from switching the light off under a viewer who came meanwhile (SP-C11).
     """
     """
     if printer is None:
     if printer is None:
         yield
         yield
         return
         return
     mode, delay = await _light_settings()
     mode, delay = await _light_settings()
-    if mode == "off" or (mode == "selected" and getattr(printer, "camera_light_auto", False) is not True):
+    if settle is not None:
+        delay = settle
+    if not any_mode and (
+        mode == "off" or (mode == "selected" and getattr(printer, "camera_light_auto", False) is not True)
+    ):
         yield
         yield
         return
         return
 
 

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

@@ -826,3 +826,25 @@ class TestCameraStreamPoolHygiene:
                 f"camera_stream re-introduced a get_db-held session via parameter {name!r} — "
                 f"camera_stream re-introduced a get_db-held session via parameter {name!r} — "
                 "it would stay open for the entire stream (issue #2572)"
                 "it would stay open for the entire stream (issue #2572)"
             )
             )
+
+    @pytest.mark.parametrize(
+        "route",
+        [
+            "test_camera",
+            "diagnose_camera_route",
+            "test_external_camera",
+            "check_plate_empty",
+            "calibrate_plate_detection",
+        ],
+    )
+    def test_capturing_routes_do_not_hold_a_get_db_session(self, route):
+        """RC-C10: these capture, diagnose or run OpenCV for 10-30 s after one
+        printer lookup; a ``Depends(get_db)`` session pinned a pooled
+        connection idle-in-transaction for all of it."""
+        import inspect
+
+        from backend.app.api.routes import camera
+
+        for name, param in inspect.signature(getattr(camera, route)).parameters.items():
+            dependency = getattr(param.default, "dependency", None)
+            assert dependency is not camera.get_db, f"{route} holds a get_db session via {name!r} (RC-C10)"

+ 81 - 0
backend/tests/integration/test_external_live_view_defers_captures.py

@@ -0,0 +1,81 @@
+"""One-shot captures wait for a live view of an external MJPEG camera (SP-C7).
+
+An ESP32-style MJPEG camera serves one client. Its live view ran no process
+the stream registry tracks, so ``is_stream_active`` said no viewer was
+attached, and timelapse frames, finish photos, Obico and the plate check
+opened a second connection the camera refused. A camera with a separate
+snapshot URL is left out: that URL serves captures beside the live view, at
+full quality, and the plate check's references come from it (#1177).
+"""
+
+import asyncio
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.api.routes import camera
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+FRAME = b"\xff\xd8frame\xff\xd9"
+
+
+@pytest.fixture(autouse=True)
+def _forget_frames():
+    yield
+    for state in (camera._last_frames, camera._last_frame_times, camera._stream_start_times):
+        state.clear()
+
+
+async def _watch(async_client: AsyncClient, printer_id: int) -> dict:
+    seen: dict = {}
+
+    async def _stream(*_args, on_frame=None, **_kwargs):
+        on_frame(FRAME)
+        yield b"--frame\r\nContent-Type: image/jpeg\r\n\r\n\xff\xd8\xff\xd9\r\n"
+        await asyncio.sleep(0.05)
+        seen["active"] = camera.is_stream_active(printer_id)
+        seen["defer"] = camera.live_frame_for_capture(printer_id)[0]
+        yield b"--frame\r\nContent-Type: image/jpeg\r\n\r\n\xff\xd8\xff\xd9\r\n"
+
+    with patch("backend.app.services.external_camera.generate_mjpeg_stream", _stream):
+        response = await async_client.get(f"/api/v1/printers/{printer_id}/camera/stream")
+    assert response.status_code == 200
+    await asyncio.sleep(0.1)
+    seen["after"] = camera.is_stream_active(printer_id)
+    seen["frame_after"] = camera._last_frames.get(printer_id)
+    return seen
+
+
+async def test_a_watched_mjpeg_camera_is_not_dialled_again(async_client: AsyncClient, printer_factory):
+    printer = await printer_factory(
+        external_camera_enabled=True,
+        external_camera_url="http://192.0.2.50:81/stream",
+        external_camera_type="mjpeg",
+    )
+
+    seen = await _watch(async_client, printer.id)
+
+    assert seen["active"] is True
+    assert seen["defer"] is True
+    assert seen["after"] is False
+    # The camera's upstream ended while the viewer was still counted: its last
+    # frame must not outlive it, or a plate check or the next viewer's start
+    # takes an old picture for a current one.
+    assert seen["frame_after"] is None
+
+
+async def test_a_camera_with_a_snapshot_url_keeps_using_it(async_client: AsyncClient, printer_factory):
+    printer = await printer_factory(
+        external_camera_enabled=True,
+        # Its own URL: a fan-out key of its own, not the first test's upstream
+        # still in its grace.
+        external_camera_url="http://192.0.2.51:81/stream",
+        external_camera_type="mjpeg",
+        external_camera_snapshot_url="http://192.0.2.51/capture",
+    )
+
+    seen = await _watch(async_client, printer.id)
+
+    assert seen["active"] is False

+ 34 - 0
backend/tests/unit/services/test_camera_light.py

@@ -307,3 +307,37 @@ class TestTheSettings:
         monkeypatch.setattr("backend.app.api.routes.settings.get_setting", _get_setting)
         monkeypatch.setattr("backend.app.api.routes.settings.get_setting", _get_setting)
         monkeypatch.setattr("backend.app.core.database.async_session", _session)
         monkeypatch.setattr("backend.app.core.database.async_session", _session)
         assert await _real_read_settings() == expected
         assert await _real_read_settings() == expected
+
+
+class TestThePlateCheck:
+    """SP-C11: the plate check lit the chamber itself, outside the count, and
+    switched it off when done -- under a live view opened meanwhile, which had
+    found the light on and so never took it."""
+
+    @pytest.mark.asyncio
+    async def test_it_lights_the_chamber_whatever_the_mode(self, client, settings):
+        settings.mode = "off"
+        async with camera_light(_printer(), any_mode=True, settle=0):
+            assert client.calls == [True]
+        await _past_grace()
+        assert client.calls == [True, False]
+
+    @pytest.mark.asyncio
+    async def test_a_viewer_who_came_meanwhile_keeps_the_light(self, client):
+        printer = _printer()
+        async with camera_light(printer, any_mode=True, settle=0):
+            viewer = camera_light(printer, wait=False)
+            await viewer.__aenter__()
+        await _past_grace()
+        assert client.calls == [True]
+        await viewer.__aexit__(None, None, None)
+        await _past_grace()
+        assert client.calls == [True, False]
+
+    @pytest.mark.asyncio
+    async def test_it_waits_its_own_settle_time(self, client, settings):
+        settings.delay = 0
+        start = time.monotonic()
+        async with camera_light(_printer(), any_mode=True, settle=0.2):
+            waited = time.monotonic() - start
+        assert waited >= 0.2

+ 61 - 0
backend/tests/unit/test_camera_janitor_spares_reconnect.py

@@ -0,0 +1,61 @@
+"""The cleanup tick leaves a reconnecting stream's entry alone (RC-C13).
+
+An X1C ends every RTSP session after about a minute. Between its ffmpeg
+exiting and the next one starting, the stream's entry still holds the dead
+process while the generator tears it down. A cleanup tick in that window
+removed the entry; the generator then read "removed" as "stopped", logged
+"stopping reconnect" and ended the live view, and one-shot captures
+meanwhile saw no stream and dialled the camera themselves.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import pytest
+
+from backend.app.api.routes import camera
+
+pytestmark = pytest.mark.asyncio
+
+SID = "7-fanout-deadbeef"
+
+
+@pytest.fixture
+def dead_stream():
+    camera._active_streams[SID] = SimpleNamespace(pid=999_999_999, returncode=0)
+    yield
+    camera._active_streams.pop(SID, None)
+    camera._disconnect_events.pop(SID, None)
+
+
+async def _cleanup():
+    with patch.object(camera, "_scan_bambu_ffmpeg_pids", return_value=[]):
+        await asyncio.wait_for(camera.cleanup_orphaned_streams(), timeout=2.0)
+
+
+async def test_an_entry_whose_generator_will_redial_is_kept(dead_stream):
+    camera._disconnect_events[SID] = asyncio.Event()
+
+    await _cleanup()
+
+    assert SID in camera._active_streams
+    assert camera.is_stream_active(7)
+
+
+async def test_a_stopped_stream_s_entry_is_removed(dead_stream):
+    stopped = asyncio.Event()
+    stopped.set()
+    camera._disconnect_events[SID] = stopped
+
+    await _cleanup()
+
+    assert SID not in camera._active_streams
+
+
+async def test_an_entry_nothing_owns_is_removed(dead_stream):
+    await _cleanup()
+
+    assert SID not in camera._active_streams

+ 2 - 0
backend/tests/unit/test_forget_printer.py

@@ -173,6 +173,7 @@ async def test_forget_printer_clears_the_services():
     camera._last_frame_times[GONE] = 1.0
     camera._last_frame_times[GONE] = 1.0
     camera._stream_start_times[GONE] = 1.0
     camera._stream_start_times[GONE] = 1.0
     camera._active_external_streams[GONE] = 1
     camera._active_external_streams[GONE] = 1
+    camera._external_live_readers[GONE] = 1
     from backend.app.api.routes import printers
     from backend.app.api.routes import printers
 
 
     printers._cover_cache[GONE] = {("Cube", "front"): b"png"}
     printers._cover_cache[GONE] = {("Cube", "front"): b"png"}
@@ -198,6 +199,7 @@ async def test_forget_printer_clears_the_services():
     assert GONE not in camera._last_frame_times
     assert GONE not in camera._last_frame_times
     assert GONE not in camera._stream_start_times
     assert GONE not in camera._stream_start_times
     assert GONE not in camera._active_external_streams
     assert GONE not in camera._active_external_streams
+    assert GONE not in camera._external_live_readers
     assert GONE not in printers._cover_cache
     assert GONE not in printers._cover_cache
     assert GONE not in printers._cover_404_cache
     assert GONE not in printers._cover_404_cache
     assert not [key for key in printer_file_cache._entries if key[0] == GONE]
     assert not [key for key in printer_file_cache._entries if key[0] == GONE]

+ 60 - 10
backend/tests/unit/test_plate_check_chamber_light.py

@@ -1,11 +1,14 @@
 """Plate check: the chamber light it switched on is always switched back off.
 """Plate check: the chamber light it switched on is always switched back off.
 
 
 on_print_start turns the chamber light on for plate detection when it was
 on_print_start turns the chamber light on for plate detection when it was
-off. It must go back off as soon as the camera is done with it -- before the
-broadcast and the provider sends -- and also when anything on the way raises,
-or the printer is left lit for the rest of the print.
+off. It must go back off once the camera is done with it -- also when
+anything on the way raises, or the printer is left lit for the rest of the
+print. It holds the light through the camera-light count rather than
+switching it itself (SP-C11), so it goes off after that count's grace, and
+not at all while a live view opened meanwhile still holds it.
 """
 """
 
 
+import asyncio
 from types import SimpleNamespace
 from types import SimpleNamespace
 from unittest.mock import AsyncMock, MagicMock, patch
 from unittest.mock import AsyncMock, MagicMock, patch
 
 
@@ -21,6 +24,18 @@ from backend.app.main import (
 )
 )
 
 
 
 
+@pytest.fixture(autouse=True)
+def _light_count(monkeypatch):
+    from backend.app.services import camera_light as svc
+
+    monkeypatch.setattr(svc, "_holders", {})
+    monkeypatch.setattr(svc, "_lit_at", {})
+    monkeypatch.setattr(svc, "_off_tasks", {})
+    monkeypatch.setattr(svc, "OFF_GRACE_SECONDS", 0)
+    # The camera-light mode is off: the plate check lights the chamber anyway.
+    monkeypatch.setattr(svc, "_light_settings", AsyncMock(return_value=("off", 2.0)))
+
+
 @pytest.fixture(autouse=True)
 @pytest.fixture(autouse=True)
 def _clear_dicts():
 def _clear_dicts():
     dicts = (
     dicts = (
@@ -60,9 +75,18 @@ def _printer():
 async def _run_print_start(events: list[str], *, check_plate_empty, pause_side_effect=None):
 async def _run_print_start(events: list[str], *, check_plate_empty, pause_side_effect=None):
     printer = _printer()
     printer = _printer()
 
 
+    async def snapshot(*_args):
+        events.append("snapshot")
+        return b"jpeg"
+
     client = MagicMock()
     client = MagicMock()
     client.state = SimpleNamespace(chamber_light=False)
     client.state = SimpleNamespace(chamber_light=False)
-    client.set_chamber_light = MagicMock(side_effect=lambda on: events.append(f"light:{'on' if on else 'off'}"))
+
+    def set_light(on: bool) -> bool:
+        events.append(f"light:{'on' if on else 'off'}")
+        return True
+
+    client.set_chamber_light = MagicMock(side_effect=set_light)
     client.pause_print = MagicMock(side_effect=pause_side_effect or (lambda: events.append("pause")))
     client.pause_print = MagicMock(side_effect=pause_side_effect or (lambda: events.append("pause")))
 
 
     def execute_router(stmt, *args, **kwargs):
     def execute_router(stmt, *args, **kwargs):
@@ -93,7 +117,8 @@ async def _run_print_start(events: list[str], *, check_plate_empty, pause_side_e
         patch("backend.app.main.printer_manager") as mock_pm,
         patch("backend.app.main.printer_manager") as mock_pm,
         patch("backend.app.main.mqtt_relay") as mock_relay,
         patch("backend.app.main.mqtt_relay") as mock_relay,
         patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
         patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
-        patch("backend.app.main._capture_snapshot_for_notification", new_callable=AsyncMock, return_value=b"jpeg"),
+        patch("backend.app.main._capture_snapshot_for_notification", side_effect=snapshot),
+        patch("backend.app.services.camera_light._get_client", return_value=client),
         patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
         patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
         patch("backend.app.main._load_objects_from_archive"),
         patch("backend.app.main._load_objects_from_archive"),
         patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
         patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
@@ -113,6 +138,10 @@ async def _run_print_start(events: list[str], *, check_plate_empty, pause_side_e
         from backend.app.main import on_print_start
         from backend.app.main import on_print_start
 
 
         await on_print_start(1, {"filename": "Benchy.3mf", "subtask_name": "Benchy"})
         await on_print_start(1, {"filename": "Benchy.3mf", "subtask_name": "Benchy"})
+        # The light goes off after the camera-light grace (0 here).
+        from backend.app.services import camera_light as svc
+
+        await asyncio.gather(*svc._off_tasks.values())
 
 
 
 
 def _objects_on_plate():
 def _objects_on_plate():
@@ -122,14 +151,13 @@ def _objects_on_plate():
 
 
 
 
 @pytest.mark.asyncio
 @pytest.mark.asyncio
-async def test_light_goes_off_before_the_notifications_are_sent():
+async def test_light_is_on_for_the_photo_and_goes_off_after():
     events: list[str] = []
     events: list[str] = []
 
 
     await _run_print_start(events, check_plate_empty=_objects_on_plate())
     await _run_print_start(events, check_plate_empty=_objects_on_plate())
 
 
-    assert events.index("light:on") < events.index("light:off")
-    assert events.index("light:off") < events.index("broadcast")
-    assert events.index("light:off") < events.index("notify")
+    assert events.index("light:on") < events.index("snapshot") < events.index("light:off")
+    assert events.count("light:off") == 1
 
 
 
 
 @pytest.mark.asyncio
 @pytest.mark.asyncio
@@ -138,7 +166,29 @@ async def test_light_goes_off_when_detection_raises():
 
 
     await _run_print_start(events, check_plate_empty=AsyncMock(side_effect=RuntimeError("camera gone")))
     await _run_print_start(events, check_plate_empty=AsyncMock(side_effect=RuntimeError("camera gone")))
 
 
-    assert events == ["light:on", "light:off", "broadcast"]
+    assert events.count("light:on") == events.count("light:off") == 1
+    assert events.index("light:on") < events.index("light:off")
+
+
+@pytest.mark.asyncio
+async def test_a_live_view_opened_during_the_check_keeps_the_light():
+    """SP-C11: the viewer found the light already on and so never took it;
+    the check then switched it off under them."""
+    from backend.app.services.camera_light import camera_light
+
+    events: list[str] = []
+    viewer = camera_light(_printer(), wait=False)
+
+    async def check_while_a_viewer_arrives(**_kwargs):
+        await viewer.__aenter__()
+        return SimpleNamespace(needs_calibration=False, is_empty=True, confidence=0.9, difference_percent=0.0)
+
+    with patch("backend.app.services.camera_light._light_settings", AsyncMock(return_value=("all", 0.0))):
+        await _run_print_start(events, check_plate_empty=AsyncMock(side_effect=check_while_a_viewer_arrives))
+
+    assert "light:on" in events
+    assert "light:off" not in events
+    await viewer.__aexit__(None, None, None)
 
 
 
 
 @pytest.mark.asyncio
 @pytest.mark.asyncio