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

Turn on the chamber light while the camera is in use

A printer kept dark between uses showed a black live view and sent black
notification photos (#1655). Settings → Camera can turn the light on for
all printers or for selected ones, for the live view, notification
photos, the finish photo and the snapshot API that Home Assistant uses,
and turns it off 15 seconds after the last use. It only turns off a
light it turned on, and switching the light by hand hands it back.
Snapshots wait an adjustable delay after the light comes on. Timelapse,
the Obico check and the in-print frame bank don't switch it, since they
would make it flash.

The camera card no longer lists every printer: external cameras are
added from a searchable printer picker and only those printers are
listed, so large farms get a short page.
maziggy 2 дней назад
Родитель
Сommit
6954cff3ee
41 измененных файлов с 1948 добавлено и 245 удалено
  1. 6 0
      CHANGELOG.md
  2. 38 24
      backend/app/api/routes/camera.py
  3. 3 1
      backend/app/api/routes/printers.py
  4. 6 0
      backend/app/api/routes/settings.py
  5. 1 0
      backend/app/api/routes/support.py
  6. 11 0
      backend/app/core/database.py
  7. 122 100
      backend/app/main.py
  8. 3 0
      backend/app/models/printer.py
  9. 4 0
      backend/app/schemas/printer.py
  10. 34 0
      backend/app/schemas/settings.py
  11. 199 0
      backend/app/services/camera_light.py
  12. 225 0
      backend/tests/integration/test_camera_light_api.py
  13. 309 0
      backend/tests/unit/services/test_camera_light.py
  14. 161 2
      backend/tests/unit/test_finish_photo_moment_sync.py
  15. 226 0
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  16. 5 0
      frontend/src/api/client.ts
  17. 105 0
      frontend/src/components/PrinterSearchPicker.tsx
  18. 16 0
      frontend/src/i18n/locales/de.ts
  19. 16 0
      frontend/src/i18n/locales/en.ts
  20. 16 0
      frontend/src/i18n/locales/es.ts
  21. 16 0
      frontend/src/i18n/locales/fr.ts
  22. 16 0
      frontend/src/i18n/locales/it.ts
  23. 16 0
      frontend/src/i18n/locales/ja.ts
  24. 16 0
      frontend/src/i18n/locales/ko.ts
  25. 16 0
      frontend/src/i18n/locales/nl.ts
  26. 16 0
      frontend/src/i18n/locales/pt-BR.ts
  27. 16 0
      frontend/src/i18n/locales/ru.ts
  28. 16 0
      frontend/src/i18n/locales/sv.ts
  29. 16 0
      frontend/src/i18n/locales/tr.ts
  30. 16 0
      frontend/src/i18n/locales/uk.ts
  31. 16 0
      frontend/src/i18n/locales/zh-CN.ts
  32. 16 0
      frontend/src/i18n/locales/zh-TW.ts
  33. 246 113
      frontend/src/pages/SettingsPage.tsx
  34. 0 0
      static/assets/ImagePreviewModal-BnY1sAGY.js
  35. 0 1
      static/assets/PdfPreviewModal-CHu3pQMX.js
  36. 0 0
      static/assets/SpreadsheetPreviewModal-Bao39I9F.js
  37. 1 0
      static/assets/index-D1FhwA-P.css
  38. 0 1
      static/assets/index-D1nJM-VU.css
  39. 1 1
      static/assets/index-J6DEE_YS.js
  40. 0 0
      static/assets/pdf-CnF50rQ7.js
  41. 2 2
      static/index.html

+ 6 - 0
CHANGELOG.md

@@ -5,6 +5,11 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 ## [1.2.6b1] - Unreleased
 
 
 ### Added
 ### Added
+- **The chamber light can turn on whenever the camera is used (#1655, requested by @dadino)** — A printer kept dark between uses showed a black live view and sent black photos with its notifications. Settings → Camera has a new **Turn on chamber light for camera** choice: **Off** (the default), **All printers**, or **Selected printers**, picked from a list you can search by name or location.
+  - **When it turns on:** while anyone watches the live view, including the camera wall and a streaming overlay, and for automatic pictures: notification photos, the finish photo and the snapshot that Home Assistant and other automations fetch.
+  - **When it turns off:** 15 seconds after the last use, so a reconnecting viewer or the camera wall's refresh doesn't make it flash. Bambuddy only turns off a light it turned on: one that was already on stays on, and switching the light by hand on the printer card hands it back to you.
+  - **Wait before snapshot** (2 seconds by default, up to 5, for all printers) gives the light, and any light Home Assistant syncs to it, time to come on before an automatic picture. It applies only when the light was off; the live view never waits.
+  - **Not included:** the layer timelapse, the Obico check and the frames Bambuddy keeps during a print for the finish photo. They capture all through a print, and the light would flash with every frame.
 - **A page for managing printer locations (#2962, requested and contributed by @SVAN19 in #2995)** — Printer locations used to be a free-text field in each printer's settings, so renaming a room meant editing every printer in it, and a typo quietly made a second location. The new **Printer Locations** page, opened from the button next to the sort control on the Printers page, lists every location with its printers and their status. You can create a location before any printer is in it, give it an icon and a colour, rename it, delete it, and move printers between locations one at a time or several at once. Search, sorting and hiding empty locations help on a large farm. Locations are stored by Bambuddy, so every user and every device sees the same ones. Renaming a location also updates queued jobs that target it, so a job waiting for "any X1C in Workshop" still finds its printers after Workshop becomes Garage, and so do the next runs of a batch. Locations typed with stray spaces before are tidied up on upgrade, so "Workshop " and "Workshop" become one. Each change is saved as one step, so a failure cannot leave a location half renamed, and two locations that differ only in upper and lower case are refused. Location names are limited to 100 characters, in the printer dialog too. Changing locations needs the printer edit permission; anyone who can see printers can view the page. Wiki updated. Covered by backend and frontend tests.
 - **A page for managing printer locations (#2962, requested and contributed by @SVAN19 in #2995)** — Printer locations used to be a free-text field in each printer's settings, so renaming a room meant editing every printer in it, and a typo quietly made a second location. The new **Printer Locations** page, opened from the button next to the sort control on the Printers page, lists every location with its printers and their status. You can create a location before any printer is in it, give it an icon and a colour, rename it, delete it, and move printers between locations one at a time or several at once. Search, sorting and hiding empty locations help on a large farm. Locations are stored by Bambuddy, so every user and every device sees the same ones. Renaming a location also updates queued jobs that target it, so a job waiting for "any X1C in Workshop" still finds its printers after Workshop becomes Garage, and so do the next runs of a batch. Locations typed with stray spaces before are tidied up on upgrade, so "Workshop " and "Workshop" become one. Each change is saved as one step, so a failure cannot leave a location half renamed, and two locations that differ only in upper and lower case are refused. Location names are limited to 100 characters, in the printer dialog too. Changing locations needs the printer edit permission; anyone who can see printers can view the page. Wiki updated. Covered by backend and frontend tests.
 - **A Column view in the File Manager (#3020, requested and contributed by @Thomansky in #3190)** — Next to Grid and List there is now **Column view**, as in the macOS Finder: one column per folder level, each listing that level's subfolders and then its own files, with the selected folder's files in the pane on the right. It shows where you are in the folder tree and lets you walk up and down it without going back to the sidebar. File rows carry the same actions as the list view (Print, Slice, Preview, Download, Details, Rename, Delete and the rest), and folder rows the same ⋮ menu as the sidebar. It works from the keyboard too: ↑/↓ move within a column, → or Enter go down a level, ← goes up, Enter opens a file's preview, Space ticks it, and the context-menu key or Shift+F10 moves into the row's actions. A file ticked in any column counts for the toolbar's Print, Preview, Combine, Move and Delete. With a search or tag filter active the columns step aside and the results take the full width, since they span every folder. The choice is remembered like Grid and List. Changing folders now clears the selection in every view, so Move and Delete can't act on files that are no longer on screen. The sidebar's folder ⋮ menu now closes when you click it again.
 - **A Column view in the File Manager (#3020, requested and contributed by @Thomansky in #3190)** — Next to Grid and List there is now **Column view**, as in the macOS Finder: one column per folder level, each listing that level's subfolders and then its own files, with the selected folder's files in the pane on the right. It shows where you are in the folder tree and lets you walk up and down it without going back to the sidebar. File rows carry the same actions as the list view (Print, Slice, Preview, Download, Details, Rename, Delete and the rest), and folder rows the same ⋮ menu as the sidebar. It works from the keyboard too: ↑/↓ move within a column, → or Enter go down a level, ← goes up, Enter opens a file's preview, Space ticks it, and the context-menu key or Shift+F10 moves into the row's actions. A file ticked in any column counts for the toolbar's Print, Preview, Combine, Move and Delete. With a search or tag filter active the columns step aside and the results take the full width, since they span every folder. The choice is remembered like Grid and List. Changing folders now clears the selection in every view, so Move and Delete can't act on files that are no longer on screen. The sidebar's folder ⋮ menu now closes when you click it again.
 - **Announcements from the Bambuddy maintainers, inside Bambuddy** — Security fixes, breaking changes, new releases and calls for testers now reach the people running Bambuddy where they already look, instead of only on GitHub and Discord. An **Announcements** entry sits at the bottom of the sidebar above the System icon for everyone who may see them, with a count of unread messages; it opens a list, and **important** and **critical** messages also show a banner until dismissed. Messages past their expiry stay readable under **Earlier** for a year (up to 50); withdrawn ones disappear everywhere. Read state is kept per user on the server. Bambuddy fetches one file, `feed.json` from the public `maziggy/bambuddy-notifications` repo on GitHub, at startup and every 6 hours: no Bambuddy server is contacted and nothing about the install is sent, and whether a message applies (version range, beta channel, install type) is decided locally. The file is signed with Ed25519 against a key built into Bambuddy, so a copy of the repo or anyone in between can't make it show a message, and an older file is refused so a withdrawn message can't be brought back. Messages are plain text, and links go only to github.com and bambuddy.cool. Shown to administrators; **Settings → General → Updates** can show them to every user, or turn them off entirely, in which case nothing is fetched.
 - **Announcements from the Bambuddy maintainers, inside Bambuddy** — Security fixes, breaking changes, new releases and calls for testers now reach the people running Bambuddy where they already look, instead of only on GitHub and Discord. An **Announcements** entry sits at the bottom of the sidebar above the System icon for everyone who may see them, with a count of unread messages; it opens a list, and **important** and **critical** messages also show a banner until dismissed. Messages past their expiry stay readable under **Earlier** for a year (up to 50); withdrawn ones disappear everywhere. Read state is kept per user on the server. Bambuddy fetches one file, `feed.json` from the public `maziggy/bambuddy-notifications` repo on GitHub, at startup and every 6 hours: no Bambuddy server is contacted and nothing about the install is sent, and whether a message applies (version range, beta channel, install type) is decided locally. The file is signed with Ed25519 against a key built into Bambuddy, so a copy of the repo or anyone in between can't make it show a message, and an older file is refused so a withdrawn message can't be brought back. Messages are plain text, and links go only to github.com and bambuddy.cool. Shown to administrators; **Settings → General → Updates** can show them to every user, or turn them off entirely, in which case nothing is fetched.
@@ -59,6 +64,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Dutch (nl) is now a supported interface language (#2891, requested and contributed by @Igiegel)** — Adds `nl` as the fourteenth locale, listed as "Nederlands" in the language picker. The translation was contributed as a file on the issue and needed three corrections before it could be wired up, all of which the parity gate found. First, the nine `stats.timeframe.*` entries had their **keys** translated along with their values (`'today'` had become `'vandaag'`), which would have left the Statistics timeframe selector resolving nothing and rendering raw key names for every Dutch user — the values were kept and the keys restored. Second, the file was translated against an older `en.ts` and was 84 leaves short, missing the Filament Track Switch feed prompts, the AI-detection status strings, the no-3MF internal-history banner, the batch-order stranded-plate notices, the Avery starting-position field and the whole `locationHaSensors` section from #2824; those were translated and added. Rather than splice them in, `nl.ts` was regenerated from the `en.ts` skeleton with the contributor's strings carried over, so its structure, key order and section comments now match the reference locale exactly and a future diff against `en.ts` reads as content rather than as reordering. Third, 229 leaves were identical to English; each was checked individually and all were kept, because Dutch takes most technical UI vocabulary verbatim — printer, filament, status, nozzle, timelapse, dashboard — and Dutch slicer users use the English feature names (support, ironing, prime tower, gap fill) untranslated. Those 123 distinct values are now listed explicitly in a `NL_COGNATES` allow-list in `check-i18n-parity.mjs`, the same shape the other twelve locales use, so the exemption is an enumerated translator decision rather than a blanket skip. Parity green at 6264 leaves across all 14 locales.
 - **Dutch (nl) is now a supported interface language (#2891, requested and contributed by @Igiegel)** — Adds `nl` as the fourteenth locale, listed as "Nederlands" in the language picker. The translation was contributed as a file on the issue and needed three corrections before it could be wired up, all of which the parity gate found. First, the nine `stats.timeframe.*` entries had their **keys** translated along with their values (`'today'` had become `'vandaag'`), which would have left the Statistics timeframe selector resolving nothing and rendering raw key names for every Dutch user — the values were kept and the keys restored. Second, the file was translated against an older `en.ts` and was 84 leaves short, missing the Filament Track Switch feed prompts, the AI-detection status strings, the no-3MF internal-history banner, the batch-order stranded-plate notices, the Avery starting-position field and the whole `locationHaSensors` section from #2824; those were translated and added. Rather than splice them in, `nl.ts` was regenerated from the `en.ts` skeleton with the contributor's strings carried over, so its structure, key order and section comments now match the reference locale exactly and a future diff against `en.ts` reads as content rather than as reordering. Third, 229 leaves were identical to English; each was checked individually and all were kept, because Dutch takes most technical UI vocabulary verbatim — printer, filament, status, nozzle, timelapse, dashboard — and Dutch slicer users use the English feature names (support, ironing, prime tower, gap fill) untranslated. Those 123 distinct values are now listed explicitly in a `NL_COGNATES` allow-list in `check-i18n-parity.mjs`, the same shape the other twelve locales use, so the exemption is an enumerated translator decision rather than a blanket skip. Parity green at 6264 leaves across all 14 locales.
 
 
 ### Changed
 ### Changed
+- **The camera settings stay short on large farms** — Settings → Camera listed every printer with its external camera switch and fields, so a farm of 120 printers got a card many screens long. The **External Cameras** list now holds only the printers that have one: **Add external camera** picks a printer from a list you can search by name or location, each row shows the camera type and address and folds its fields away, and a bin icon switches the printer back to its built-in camera.
 - **Camera settings have their own tab, and the settings menu is sorted alphabetically** — Camera settings were split across two tabs: external cameras under General, and camera tokens and the Streaming Overlay builder under API Keys. A new **Camera** tab now holds all three: **External Cameras**, **Camera API Tokens** and **Streaming Overlay**. Old links to the tokens or the overlay builder, including `/camera-tokens`, open the Camera tab. API Keys keeps API keys, webhooks, Connected Apps and the API browser, and is no longer shown to a user who may see none of them; before, such a user only went there for their camera tokens, so an API Keys link now opens the Camera tab for them. Settings search finds the Streaming Overlay builder. The settings menu now lists **General** first and the other tabs alphabetically in the interface language. In Connected Apps, the **Add app** button no longer runs past the edge of the card on mid-sized screens, and the Brazilian Portuguese camera button on the printer card reads "Câmera" again instead of "âmera". Wiki updated. Covered by frontend tests.
 - **Camera settings have their own tab, and the settings menu is sorted alphabetically** — Camera settings were split across two tabs: external cameras under General, and camera tokens and the Streaming Overlay builder under API Keys. A new **Camera** tab now holds all three: **External Cameras**, **Camera API Tokens** and **Streaming Overlay**. Old links to the tokens or the overlay builder, including `/camera-tokens`, open the Camera tab. API Keys keeps API keys, webhooks, Connected Apps and the API browser, and is no longer shown to a user who may see none of them; before, such a user only went there for their camera tokens, so an API Keys link now opens the Camera tab for them. Settings search finds the Streaming Overlay builder. The settings menu now lists **General** first and the other tabs alphabetically in the interface language. In Connected Apps, the **Add app** button no longer runs past the edge of the card on mid-sized screens, and the Brazilian Portuguese camera button on the printer card reads "Câmera" again instead of "âmera". Wiki updated. Covered by frontend tests.
 - **The Slice dialog uses more of a large screen** — The dialog stopped at 1024 px wide however large the screen, and its left column was fixed at 320 px. So preset names were cut off, and every filament row wrapped its label and its "hidden / Show all" note, while the process-settings panel beside it sat half empty. The dialog now widens to 1280 px on wide screens and 1536 px on very wide ones, and the left column takes two fifths of it instead of a fixed width. Below 1024 px it is still a single column.
 - **The Slice dialog uses more of a large screen** — The dialog stopped at 1024 px wide however large the screen, and its left column was fixed at 320 px. So preset names were cut off, and every filament row wrapped its label and its "hidden / Show all" note, while the process-settings panel beside it sat half empty. The dialog now widens to 1280 px on wide screens and 1536 px on very wide ones, and the left column takes two fifths of it instead of a fixed width. Below 1024 px it is still a single column.
 - **A STEP preview says it is converting, and for how long (#2976)** — A large STEP export can take over a minute to turn into a mesh in the browser (a 25 MB FreeCAD file took about 75 seconds; coarser meshing settings do not help, the time goes into reading the file), and for all that time the preview showed only a small spinner, which looked like a preview that never opens. STEP loads now show **Converting STEP model… N s** with a running counter and a note that large files can take a minute or more, in all 15 languages. Other formats load in a moment and keep the plain spinner.
 - **A STEP preview says it is converting, and for how long (#2976)** — A large STEP export can take over a minute to turn into a mesh in the browser (a 25 MB FreeCAD file took about 75 seconds; coarser meshing settings do not help, the time goes into reading the file), and for all that time the preview showed only a small spinner, which looked like a preview that never opens. STEP loads now show **Converting STEP model… N s** with a running counter and a note that large files can take a minute or more, in all 15 languages. Other formats load in a moment and keep the plain spinner.

+ 38 - 24
backend/app/api/routes/camera.py

@@ -45,6 +45,7 @@ from backend.app.services.camera_fanout import (
     iter_subscriber,
     iter_subscriber,
     shutdown_broadcaster,
     shutdown_broadcaster,
 )
 )
+from backend.app.services.camera_light import camera_light
 from backend.app.services.camera_profiles import get_camera_profile
 from backend.app.services.camera_profiles import get_camera_profile
 from backend.app.utils.ffmpeg_output import summarize_ffmpeg_stderr
 from backend.app.utils.ffmpeg_output import summarize_ffmpeg_stderr
 
 
@@ -1038,20 +1039,22 @@ async def camera_stream(
         async def external_stream_wrapper():
         async def external_stream_wrapper():
             """Wrap external stream to track start/stop and update frame times."""
             """Wrap external stream to track start/stop and update frame times."""
             try:
             try:
-                async for frame in generate_mjpeg_stream(
-                    printer.external_camera_url,
-                    printer.external_camera_type,
-                    fps,
-                    on_process=_register_external_process,
-                    on_frame=_publish_external_frame,
-                    stop_event=stop_event,
-                ):
-                    # generate_mjpeg_stream already handles rate limiting;
-                    # track frame times (per-printer + per-stream) for stall detection
-                    now = time.time()
-                    _last_frame_times[printer_id] = now
-                    _stream_last_frame_times[stream_id] = now
-                    yield frame
+                # Chamber light while the viewer watches (#1655).
+                async with camera_light(printer, wait=False):
+                    async for frame in generate_mjpeg_stream(
+                        printer.external_camera_url,
+                        printer.external_camera_type,
+                        fps,
+                        on_process=_register_external_process,
+                        on_frame=_publish_external_frame,
+                        stop_event=stop_event,
+                    ):
+                        # generate_mjpeg_stream already handles rate limiting;
+                        # track frame times (per-printer + per-stream) for stall detection
+                        now = time.time()
+                        _last_frame_times[printer_id] = now
+                        _stream_last_frame_times[stream_id] = now
+                        yield frame
             finally:
             finally:
                 # Best-effort unregister. If an abrupt disconnect skips this
                 # Best-effort unregister. If an abrupt disconnect skips this
                 # finally, the registry entries persist — which is exactly what
                 # finally, the registry entries persist — which is exactly what
@@ -1157,13 +1160,16 @@ async def camera_stream(
         logger.info("Camera viewer detached from %s (subscribers=%d)", fanout_key, remaining)
         logger.info("Camera viewer detached from %s (subscribers=%d)", fanout_key, remaining)
 
 
     async def _generate():
     async def _generate():
-        async for chunk in iter_subscriber(
-            broadcaster,
-            queue,
-            is_disconnected=_is_disconnected,
-            on_unsubscribe=_log_detach,
-        ):
-            yield chunk
+        # Chamber light while this viewer watches (#1655). Held per viewer, so
+        # it goes off once the last one leaves.
+        async with camera_light(printer, wait=False):
+            async for chunk in iter_subscriber(
+                broadcaster,
+                queue,
+                is_disconnected=_is_disconnected,
+                on_unsubscribe=_log_detach,
+            ):
+                yield chunk
 
 
     return StreamingResponse(
     return StreamingResponse(
         _generate(),
         _generate(),
@@ -1273,9 +1279,6 @@ async def camera_snapshot(
 
 
     Requires a stream token query param (?token=xxx) when auth is enabled.
     Requires a stream token query param (?token=xxx) when auth is enabled.
     """
     """
-    import tempfile
-    from pathlib import Path
-
     # Fetch the printer in a short-lived session and release the pooled DB
     # Fetch the printer in a short-lived session and release the pooled DB
     # connection BEFORE the camera capture below (up to 15s, longer under a
     # connection BEFORE the camera capture below (up to 15s, longer under a
     # saturated FTP/camera pool). Holding a Depends(get_db) session across the
     # saturated FTP/camera pool). Holding a Depends(get_db) session across the
@@ -1286,6 +1289,17 @@ async def camera_snapshot(
     async with database.async_session() as db:
     async with database.async_session() as db:
         printer = await get_printer_or_404(printer_id, db)
         printer = await get_printer_or_404(printer_id, db)
 
 
+    # Chamber light for the picture (#1655). Home Assistant and other
+    # automations take their pictures here.
+    async with camera_light(printer):
+        return await _snapshot_response(printer_id, printer)
+
+
+async def _snapshot_response(printer_id: int, printer: Printer) -> Response:
+    """The capture behind ``camera_snapshot``."""
+    import tempfile
+    from pathlib import Path
+
     # Check for external camera first
     # Check for external camera first
     if printer.external_camera_enabled and printer.external_camera_url:
     if printer.external_camera_enabled and printer.external_camera_url:
         from backend.app.services.external_camera import capture_frame
         from backend.app.services.external_camera import capture_frame

+ 3 - 1
backend/app/api/routes/printers.py

@@ -49,7 +49,7 @@ from backend.app.schemas.printer import (
     PrintOptionsResponse,
     PrintOptionsResponse,
     hms_error_responses,
     hms_error_responses,
 )
 )
-from backend.app.services import drying_preflight, kprofile_drift
+from backend.app.services import camera_light as camera_light_service, drying_preflight, kprofile_drift
 from backend.app.services.bambu_ftp import (
 from backend.app.services.bambu_ftp import (
     cache_3mf_download,
     cache_3mf_download,
     delete_file_async,
     delete_file_async,
@@ -3946,6 +3946,8 @@ async def set_chamber_light(
     success = client.set_chamber_light(on)
     success = client.set_chamber_light(on)
     if not success:
     if not success:
         raise HTTPException(500, "Failed to control chamber light")
         raise HTTPException(500, "Failed to control chamber light")
+    # The light is the user's now; the camera must not switch it off (#1655).
+    camera_light_service.hand_back(printer_id)
 
 
     return {"success": True, "message": f"Chamber light {'on' if on else 'off'}"}
     return {"success": True, "message": f"Chamber light {'on' if on else 'off'}"}
 
 

+ 6 - 0
backend/app/api/routes/settings.py

@@ -24,6 +24,7 @@ from backend.app.core.permissions import Permission
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
 from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
+from backend.app.services import camera_light
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -225,6 +226,7 @@ _FLOAT_SETTING_KEYS = frozenset(
     {
     {
         "default_filament_cost",
         "default_filament_cost",
         "energy_cost_per_kwh",
         "energy_cost_per_kwh",
+        "camera_light_delay",
         "ams_temp_good",
         "ams_temp_good",
         "ams_temp_fair",
         "ams_temp_fair",
         "library_disk_warning_gb",
         "library_disk_warning_gb",
@@ -396,6 +398,9 @@ async def update_settings(
     # Expire all objects to ensure fresh reads after commit
     # Expire all objects to ensure fresh reads after commit
     db.expire_all()
     db.expire_all()
 
 
+    if {"camera_light_mode", "camera_light_delay"} & set(update_data.keys()):
+        camera_light.invalidate_settings()
+
     # Reconfigure MQTT relay if any MQTT settings changed
     # Reconfigure MQTT relay if any MQTT settings changed
     if mqtt_updated:
     if mqtt_updated:
         try:
         try:
@@ -475,6 +480,7 @@ async def reset_settings(
         await db.delete(setting)
         await db.delete(setting)
 
 
     await db.commit()
     await db.commit()
+    camera_light.invalidate_settings()
 
 
     return DEFAULT_SETTINGS
     return DEFAULT_SETTINGS
 
 

+ 1 - 0
backend/app/api/routes/support.py

@@ -993,6 +993,7 @@ async def _collect_support_info() -> dict:
                     "ams_tray_count": ams_tray_count,
                     "ams_tray_count": ams_tray_count,
                     "has_vt_tray": has_vt_tray,
                     "has_vt_tray": has_vt_tray,
                     "external_camera_configured": bool(printer.external_camera_url),
                     "external_camera_configured": bool(printer.external_camera_url),
+                    "camera_light_auto": bool(printer.camera_light_auto),
                     "plate_detection_enabled": printer.plate_detection_enabled,
                     "plate_detection_enabled": printer.plate_detection_enabled,
                     "obico_enabled": obico_globally_enabled
                     "obico_enabled": obico_globally_enabled
                     and (obico_enabled_set is None or printer.id in obico_enabled_set),
                     and (obico_enabled_set is None or printer.id in obico_enabled_set),

+ 11 - 0
backend/app/core/database.py

@@ -5236,6 +5236,17 @@ async def run_migrations(conn):
     # Nullable: existing rows have none and keep being shown as before.
     # Nullable: existing rows have none and keep being shown as before.
     await _safe_execute(conn, "ALTER TABLE slot_preset_mappings ADD COLUMN tray_info_idx VARCHAR(32)")
     await _safe_execute(conn, "ALTER TABLE slot_preset_mappings ADD COLUMN tray_info_idx VARCHAR(32)")
 
 
+    # Migration: printers picked for the chamber light while the camera is in
+    # use (#1655), for camera_light_mode "selected". Off by default, so no
+    # printer's light changes on upgrade. The backfill covers a table
+    # create_all() already gave the column (the ALTER is then swallowed and
+    # existing rows keep NULL).
+    await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN camera_light_auto BOOLEAN DEFAULT FALSE")
+    async with conn.begin_nested():
+        await conn.execute(
+            text("UPDATE printers SET camera_light_auto = :off WHERE camera_light_auto IS NULL"), {"off": False}
+        )
+
 
 
 async def _migrate_confirm_prompt_body_template(conn) -> None:
 async def _migrate_confirm_prompt_body_template(conn) -> None:
     """Replace the one-tap verdict URLs in the outcome prompt's body (#1898).
     """Replace the one-tap verdict URLs in the outcome prompt's body (#1898).

+ 122 - 100
backend/app/main.py

@@ -106,6 +106,7 @@ from backend.app.services.bambu_ftp import (
     with_ftp_retry,
     with_ftp_retry,
 )
 )
 from backend.app.services.bambu_mqtt import PrinterState
 from backend.app.services.bambu_mqtt import PrinterState
+from backend.app.services.camera_light import camera_light
 from backend.app.services.energy_plug import energy_plug_candidates, select_energy_reading
 from backend.app.services.energy_plug import energy_plug_candidates, select_energy_reading
 from backend.app.services.github_backup import github_backup_service
 from backend.app.services.github_backup import github_backup_service
 from backend.app.services.ha_sensor_manager import ha_sensor_manager
 from backend.app.services.ha_sensor_manager import ha_sensor_manager
@@ -3275,15 +3276,26 @@ async def _finish_photo_for_notification(
     return url, photo_bytes
     return url, photo_bytes
 
 
 
 
-async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -> bytes | None:
+async def _capture_snapshot_for_notification(printer_id: int, printer, logger, *, light: bool = True) -> bytes | None:
     """Capture a camera snapshot for notification image attachment.
     """Capture a camera snapshot for notification image attachment.
 
 
     Returns JPEG bytes (max 2.5MB) or None if capture fails or is unavailable.
     Returns JPEG bytes (max 2.5MB) or None if capture fails or is unavailable.
     Uses: external camera > buffered frame > fresh capture.
     Uses: external camera > buffered frame > fresh capture.
+
+    Turns the chamber light on for the picture when the printer asks for it
+    (#1655). ``light=False`` is for callers that capture all through a print,
+    where the light would flash with every frame.
     """
     """
     if not printer:
     if not printer:
         return None
         return None
 
 
+    async with camera_light(printer if light else None):
+        return await _capture_snapshot_frame(printer_id, printer, logger)
+
+
+async def _capture_snapshot_frame(printer_id: int, printer, logger) -> bytes | None:
+    """The capture behind ``_capture_snapshot_for_notification``."""
+
     try:
     try:
         from backend.app.api.routes.settings import get_setting
         from backend.app.api.routes.settings import get_setting
 
 
@@ -3394,8 +3406,9 @@ async def _maybe_bank_inprint_frame(printer_id: int, layer_num: int) -> None:
             return
             return
         # Reuses the notification snapshot path, which honours the
         # Reuses the notification snapshot path, which honours the
         # `capture_finish_photo` setting (returns None when disabled) so we
         # `capture_finish_photo` setting (returns None when disabled) so we
-        # don't bank frames the user never asked for.
-        frame = await _capture_snapshot_for_notification(printer_id, printer, logger)
+        # don't bank frames the user never asked for. Without the chamber light
+        # (#1655): this runs every 25 seconds for the whole print.
+        frame = await _capture_snapshot_for_notification(printer_id, printer, logger, light=False)
         if frame:
         if frame:
             _inprint_frame_bank[printer_id] = frame
             _inprint_frame_bank[printer_id] = frame
             _inprint_frame_bank_ts[printer_id] = now
             _inprint_frame_bank_ts[printer_id] = now
@@ -6459,7 +6472,9 @@ _PLATE_RESTORE_SETTLE_SECONDS = 12.0
 # How long `_background_finish_photo` waits for this producer. Must cover the
 # How long `_background_finish_photo` waits for this producer. Must cover the
 # settle window plus a worst-case RTSP grab (15s), and stay below the
 # settle window plus a worst-case RTSP grab (15s), and stay below the
 # notification path's own photo wait so a slow producer degrades to a
 # notification path's own photo wait so a slow producer degrades to a
-# photo-less notification rather than a missed one.
+# photo-less notification rather than a missed one. The chamber light's
+# snapshot delay (#1655) spends from the spare 8 seconds, which is why
+# camera_light.MAX_DELAY_SECONDS stays below it.
 _FINISH_PHOTO_PRODUCER_WAIT_SECONDS = _PLATE_RESTORE_SETTLE_SECONDS + 23.0
 _FINISH_PHOTO_PRODUCER_WAIT_SECONDS = _PLATE_RESTORE_SETTLE_SECONDS + 23.0
 
 
 
 
@@ -6779,51 +6794,54 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
             elif await _restore_plate_for_finish_photo(printer_id, wants_restore, logger):
             elif await _restore_plate_for_finish_photo(printer_id, wants_restore, logger):
                 restore_max_z = wants_restore
                 restore_max_z = wants_restore
 
 
-        if frame_bytes is None and printer.external_camera_enabled and printer.external_camera_url:
-            from backend.app.api.routes.camera import live_frame_for_capture
-            from backend.app.services.external_camera import capture_frame
-
-            # #2707: this used to collide with the live view and fail, which is
-            # how finish-photo notifications went out with no image attached.
-            # Leaving frame_bytes None keeps the rest of the fallback chain.
-            defer, buffered = live_frame_for_capture(printer_id)
-            if defer:
-                frame_bytes = buffered
-            else:
-                frame_bytes = await capture_frame(
-                    printer.external_camera_url,
-                    printer.external_camera_type or "mjpeg",
-                    snapshot_url=printer.external_camera_snapshot_url,
-                )
-            if frame_bytes:
-                logger.info(
-                    "[FINISH-PHOTO-MOMENT] captured external-camera frame (%d bytes)",
-                    len(frame_bytes),
-                )
-        elif frame_bytes is None:
-            from backend.app.api.routes.camera import get_buffered_frame
-
-            buffered = get_buffered_frame(printer_id)
-            if buffered:
-                frame_bytes = buffered
-                logger.info(
-                    "[FINISH-PHOTO-MOMENT] used buffered RTSP frame (%d bytes)",
-                    len(frame_bytes),
-                )
-            else:
-                from backend.app.services.camera import capture_camera_frame_bytes
-
-                frame_bytes = await capture_camera_frame_bytes(
-                    ip_address=printer.ip_address,
-                    access_code=printer.access_code,
-                    model=printer.model,
-                    timeout=15,
-                )
+        # Chamber light for the live capture below (#1655). The banked frame
+        # above was taken without it, like every in-print bank.
+        async with camera_light(printer if frame_bytes is None else None):
+            if frame_bytes is None and printer.external_camera_enabled and printer.external_camera_url:
+                from backend.app.api.routes.camera import live_frame_for_capture
+                from backend.app.services.external_camera import capture_frame
+
+                # #2707: this used to collide with the live view and fail, which is
+                # how finish-photo notifications went out with no image attached.
+                # Leaving frame_bytes None keeps the rest of the fallback chain.
+                defer, buffered = live_frame_for_capture(printer_id)
+                if defer:
+                    frame_bytes = buffered
+                else:
+                    frame_bytes = await capture_frame(
+                        printer.external_camera_url,
+                        printer.external_camera_type or "mjpeg",
+                        snapshot_url=printer.external_camera_snapshot_url,
+                    )
                 if frame_bytes:
                 if frame_bytes:
                     logger.info(
                     logger.info(
-                        "[FINISH-PHOTO-MOMENT] captured RTSP frame (%d bytes)",
+                        "[FINISH-PHOTO-MOMENT] captured external-camera frame (%d bytes)",
                         len(frame_bytes),
                         len(frame_bytes),
                     )
                     )
+            elif frame_bytes is None:
+                from backend.app.api.routes.camera import get_buffered_frame
+
+                buffered = get_buffered_frame(printer_id)
+                if buffered:
+                    frame_bytes = buffered
+                    logger.info(
+                        "[FINISH-PHOTO-MOMENT] used buffered RTSP frame (%d bytes)",
+                        len(frame_bytes),
+                    )
+                else:
+                    from backend.app.services.camera import capture_camera_frame_bytes
+
+                    frame_bytes = await capture_camera_frame_bytes(
+                        ip_address=printer.ip_address,
+                        access_code=printer.access_code,
+                        model=printer.model,
+                        timeout=15,
+                    )
+                    if frame_bytes:
+                        logger.info(
+                            "[FINISH-PHOTO-MOMENT] captured RTSP frame (%d bytes)",
+                            len(frame_bytes),
+                        )
 
 
         if frame_bytes:
         if frame_bytes:
             if not frame_already_rotated:
             if not frame_already_rotated:
@@ -8152,63 +8170,67 @@ async def on_print_complete(printer_id: int, data: dict):
             # Fallback chain: external camera → buffered live frame →
             # Fallback chain: external camera → buffered live frame →
             # fresh RTSP capture. Only runs if the timelapse path above
             # fresh RTSP capture. Only runs if the timelapse path above
             # didn't already produce a photo.
             # didn't already produce a photo.
-            if not photo_filename:
-                if printer.external_camera_enabled and printer.external_camera_url:
-                    logger.info("[PHOTO-BG] Using external camera")
-                    from backend.app.api.routes.camera import live_frame_for_capture
-                    from backend.app.services.external_camera import capture_frame
-
-                    # #2707: the second half of the finish-photo failure — the
-                    # pre-capture and this fallback both collided with the live
-                    # view. None here continues down the fallback chain.
-                    defer, buffered = live_frame_for_capture(printer_id)
-                    if defer:
-                        frame_data = buffered
-                    else:
-                        frame_data = await capture_frame(
-                            printer.external_camera_url,
-                            printer.external_camera_type or "mjpeg",
-                            snapshot_url=printer.external_camera_snapshot_url,
-                        )
-                    if frame_data:
-                        frame_data = _apply_camera_rotation(frame_data, printer, logger)
-                        photos_dir = archive_dir / "photos"
-                        photos_dir.mkdir(parents=True, exist_ok=True)
-                        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
-                        photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
-                        photo_path = photos_dir / photo_filename
-                        await asyncio.to_thread(photo_path.write_bytes, frame_data)
-                        logger.info("[PHOTO-BG] Saved external camera frame: %s", photo_filename)
-                else:
-                    # Check if camera stream is active - use buffered frame to avoid freeze
-                    # Check both RTSP streams (_active_streams) and chamber image streams (_active_chamber_streams)
-                    active_for_printer = [k for k in _active_streams if k.startswith(f"{printer_id}-")]
-                    active_chamber_for_printer = [k for k in _active_chamber_streams if k.startswith(f"{printer_id}-")]
-                    buffered_frame = get_buffered_frame(printer_id)
-
-                    if (active_for_printer or active_chamber_for_printer) and buffered_frame:
-                        # Use frame from active stream
-                        logger.info("[PHOTO-BG] Using buffered frame from active stream")
-                        buffered_frame = _apply_camera_rotation(buffered_frame, printer, logger)
-                        photos_dir = archive_dir / "photos"
-                        photos_dir.mkdir(parents=True, exist_ok=True)
-                        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
-                        photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
-                        photo_path = photos_dir / photo_filename
-                        await asyncio.to_thread(photo_path.write_bytes, buffered_frame)
-                        logger.info("[PHOTO-BG] Saved buffered frame: %s", photo_filename)
+            # Chamber light for the live capture below (#1655).
+            async with camera_light(printer if not photo_filename else None):
+                if not photo_filename:
+                    if printer.external_camera_enabled and printer.external_camera_url:
+                        logger.info("[PHOTO-BG] Using external camera")
+                        from backend.app.api.routes.camera import live_frame_for_capture
+                        from backend.app.services.external_camera import capture_frame
+
+                        # #2707: the second half of the finish-photo failure — the
+                        # pre-capture and this fallback both collided with the live
+                        # view. None here continues down the fallback chain.
+                        defer, buffered = live_frame_for_capture(printer_id)
+                        if defer:
+                            frame_data = buffered
+                        else:
+                            frame_data = await capture_frame(
+                                printer.external_camera_url,
+                                printer.external_camera_type or "mjpeg",
+                                snapshot_url=printer.external_camera_snapshot_url,
+                            )
+                        if frame_data:
+                            frame_data = _apply_camera_rotation(frame_data, printer, logger)
+                            photos_dir = archive_dir / "photos"
+                            photos_dir.mkdir(parents=True, exist_ok=True)
+                            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+                            photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
+                            photo_path = photos_dir / photo_filename
+                            await asyncio.to_thread(photo_path.write_bytes, frame_data)
+                            logger.info("[PHOTO-BG] Saved external camera frame: %s", photo_filename)
                     else:
                     else:
-                        # No active stream - capture new frame
-                        from backend.app.services.camera import capture_finish_photo
+                        # Check if camera stream is active - use buffered frame to avoid freeze
+                        # Check both RTSP streams (_active_streams) and chamber image streams (_active_chamber_streams)
+                        active_for_printer = [k for k in _active_streams if k.startswith(f"{printer_id}-")]
+                        active_chamber_for_printer = [
+                            k for k in _active_chamber_streams if k.startswith(f"{printer_id}-")
+                        ]
+                        buffered_frame = get_buffered_frame(printer_id)
+
+                        if (active_for_printer or active_chamber_for_printer) and buffered_frame:
+                            # Use frame from active stream
+                            logger.info("[PHOTO-BG] Using buffered frame from active stream")
+                            buffered_frame = _apply_camera_rotation(buffered_frame, printer, logger)
+                            photos_dir = archive_dir / "photos"
+                            photos_dir.mkdir(parents=True, exist_ok=True)
+                            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+                            photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
+                            photo_path = photos_dir / photo_filename
+                            await asyncio.to_thread(photo_path.write_bytes, buffered_frame)
+                            logger.info("[PHOTO-BG] Saved buffered frame: %s", photo_filename)
+                        else:
+                            # No active stream - capture new frame
+                            from backend.app.services.camera import capture_finish_photo
 
 
-                        photo_filename = await capture_finish_photo(
-                            printer_id=printer_id,
-                            ip_address=printer.ip_address,
-                            access_code=printer.access_code,
-                            model=printer.model,
-                            archive_dir=archive_dir,
-                            rotation=getattr(printer, "camera_rotation", 0),
-                        )
+                            photo_filename = await capture_finish_photo(
+                                printer_id=printer_id,
+                                ip_address=printer.ip_address,
+                                access_code=printer.access_code,
+                                model=printer.model,
+                                archive_dir=archive_dir,
+                                rotation=getattr(printer, "camera_rotation", 0),
+                            )
 
 
             # Write phase: attach the photo in a fresh short-lived session.
             # Write phase: attach the photo in a fresh short-lived session.
             if photo_filename:
             if photo_filename:

+ 3 - 0
backend/app/models/printer.py

@@ -36,6 +36,9 @@ class Printer(Base):
     # expose a dedicated frame endpoint (e.g. go2rtc's /api/frame.jpeg). #1177.
     # expose a dedicated frame endpoint (e.g. go2rtc's /api/frame.jpeg). #1177.
     external_camera_snapshot_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
     external_camera_snapshot_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
     camera_rotation: Mapped[int] = mapped_column(default=0)  # 0, 90, 180, 270 degrees
     camera_rotation: Mapped[int] = mapped_column(default=0)  # 0, 90, 180, 270 degrees
+    # Picked for the chamber light while the camera is in use (#1655). Only
+    # counts when the app setting camera_light_mode is "selected".
+    camera_light_auto: Mapped[bool] = mapped_column(Boolean, default=False)
     # Plate detection - check if build plate is empty before starting print
     # Plate detection - check if build plate is empty before starting print
     plate_detection_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
     plate_detection_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
     # ROI for plate detection (percentages: 0.0-1.0)
     # ROI for plate detection (percentages: 0.0-1.0)

+ 4 - 0
backend/app/schemas/printer.py

@@ -40,6 +40,7 @@ class PrinterBase(BaseModel):
     external_camera_enabled: bool = False
     external_camera_enabled: bool = False
     external_camera_snapshot_url: str | None = None  # Optional single-frame override; #1177
     external_camera_snapshot_url: str | None = None  # Optional single-frame override; #1177
     camera_rotation: int = 0  # 0, 90, 180, 270 degrees
     camera_rotation: int = 0  # 0, 90, 180, 270 degrees
+    camera_light_auto: bool = False  # #1655, used when camera_light_mode is "selected"
 
 
 
 
 class PrinterCreate(PrinterBase):
 class PrinterCreate(PrinterBase):
@@ -81,6 +82,7 @@ class PrinterUpdate(BaseModel):
     external_camera_enabled: bool | None = None
     external_camera_enabled: bool | None = None
     external_camera_snapshot_url: str | None = None  # #1177
     external_camera_snapshot_url: str | None = None  # #1177
     camera_rotation: int | None = None  # 0, 90, 180, 270 degrees
     camera_rotation: int | None = None  # 0, 90, 180, 270 degrees
+    camera_light_auto: bool | None = None  # #1655
     plate_detection_enabled: bool | None = None
     plate_detection_enabled: bool | None = None
     plate_detection_roi: PlateDetectionROI | None = None
     plate_detection_roi: PlateDetectionROI | None = None
 
 
@@ -100,6 +102,7 @@ class PrinterResponse(PrinterBase):
     external_camera_enabled: bool = False
     external_camera_enabled: bool = False
     external_camera_snapshot_url: str | None = None  # #1177
     external_camera_snapshot_url: str | None = None  # #1177
     camera_rotation: int = 0  # 0, 90, 180, 270 degrees
     camera_rotation: int = 0  # 0, 90, 180, 270 degrees
+    camera_light_auto: bool = False  # #1655
     plate_detection_enabled: bool = False
     plate_detection_enabled: bool = False
     plate_detection_roi: PlateDetectionROI | None = None
     plate_detection_roi: PlateDetectionROI | None = None
     created_at: datetime
     created_at: datetime
@@ -124,6 +127,7 @@ class PrinterResponse(PrinterBase):
             "external_camera_enabled": printer.external_camera_enabled,
             "external_camera_enabled": printer.external_camera_enabled,
             "external_camera_snapshot_url": printer.external_camera_snapshot_url,
             "external_camera_snapshot_url": printer.external_camera_snapshot_url,
             "camera_rotation": printer.camera_rotation,
             "camera_rotation": printer.camera_rotation,
+            "camera_light_auto": bool(printer.camera_light_auto),
             "is_active": printer.is_active,
             "is_active": printer.is_active,
             "nozzle_count": printer.nozzle_count,
             "nozzle_count": printer.nozzle_count,
             "supports_nozzle_flow_type": supports_nozzle_flow_type(printer.model),
             "supports_nozzle_flow_type": supports_nozzle_flow_type(printer.model),

+ 34 - 0
backend/app/schemas/settings.py

@@ -1,5 +1,6 @@
 import json
 import json
 import re
 import re
+from typing import Literal
 
 
 from pydantic import BaseModel, Field, ValidationInfo, field_validator
 from pydantic import BaseModel, Field, ValidationInfo, field_validator
 
 
@@ -291,6 +292,18 @@ class AppSettings(BaseModel):
         description="Show warning when free disk space falls below this threshold (GB)",
         description="Show warning when free disk space falls below this threshold (GB)",
     )
     )
 
 
+    # Chamber light while the camera is in use (#1655): "off", "all" printers,
+    # or the "selected" printers (Printer.camera_light_auto). A stored value
+    # outside these reads as off in the service, so it is a plain str here.
+    camera_light_mode: str = Field(
+        default="off",
+        description="Turn the chamber light on while the camera is in use: 'off', 'all' or 'selected' printers",
+    )
+    camera_light_delay: float = Field(
+        default=2.0,
+        description="Seconds an automatic snapshot waits after the chamber light was turned on",
+    )
+
     # Camera view settings
     # Camera view settings
     camera_view_mode: str = Field(
     camera_view_mode: str = Field(
         default="window",
         default="window",
@@ -679,6 +692,23 @@ class AppSettings(BaseModel):
         description="JSON object with 'order' key containing array of sidebar item IDs (empty = no default)",
         description="JSON object with 'order' key containing array of sidebar item IDs (empty = no default)",
     )
     )
 
 
+    # The settings page sends every field back on each save, and the update
+    # schema accepts only these values. A stored value outside them would make
+    # every later save fail, so it is read back as what the service treats it as.
+    @field_validator("camera_light_mode", mode="before")
+    @classmethod
+    def _known_camera_light_mode(cls, value):
+        return value if value in ("off", "all", "selected") else "off"
+
+    @field_validator("camera_light_delay", mode="before")
+    @classmethod
+    def _camera_light_delay_in_range(cls, value):
+        try:
+            seconds = float(value)
+        except (TypeError, ValueError):
+            return 2.0
+        return min(max(0.0, seconds), 5.0)
+
 
 
 class AppSettingsUpdate(BaseModel):
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
     """Schema for updating settings (all fields optional)."""
@@ -756,6 +786,10 @@ class AppSettingsUpdate(BaseModel):
     library_archive_mode: str | None = None
     library_archive_mode: str | None = None
     library_disk_warning_gb: float | None = None
     library_disk_warning_gb: float | None = None
     camera_view_mode: str | None = None
     camera_view_mode: str | None = None
+    camera_light_mode: Literal["off", "all", "selected"] | None = None
+    # Capped by camera_light.MAX_DELAY_SECONDS: the finish photo's budget
+    # has no more to spare.
+    camera_light_delay: float | None = Field(default=None, ge=0, le=5)
     preferred_slicer: str | None = None
     preferred_slicer: str | None = None
     open_in_slicer: str | None = None
     open_in_slicer: str | None = None
     slice_engine: str | None = None
     slice_engine: str | None = None

+ 199 - 0
backend/app/services/camera_light.py

@@ -0,0 +1,199 @@
+"""Chamber light for the camera (#1655).
+
+A printer kept dark between uses gives black live views and black snapshots.
+The app setting ``camera_light_mode`` picks the printers this applies to:
+"off", "all", or "selected" (those with ``Printer.camera_light_auto``). For
+those, every camera use holds the light: the first
+holder turns it on, and once the last one lets go it is turned off again after
+a short grace, so a reconnecting viewer or the camera wall's periodic
+snapshots don't make it flash.
+
+Bambuddy only ever turns off a light it turned on itself. A light that was
+already on stays on, and switching the light by hand hands it back to the user.
+
+Layer timelapse, the Obico check and the in-print frame bank don't hold the
+light: they capture all through a print, and the light would flash with every
+frame.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import time
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+logger = logging.getLogger(__name__)
+
+# Long enough to cover a viewer reconnecting and the camera wall polling each
+# tile every 8 seconds.
+OFF_GRACE_SECONDS = 15.0
+
+# Upper bound for camera_light_delay. The finish photo's producer budget
+# (_FINISH_PHOTO_PRODUCER_WAIT_SECONDS in main.py) has about 8 seconds to spare
+# beyond its plate settle and worst-case grab; a longer wait would push the
+# producer past it and into the #1790 race with the fallback grab.
+MAX_DELAY_SECONDS = 5.0
+
+# The two app settings are read at most this often: the camera wall asks for a
+# snapshot of every tile every 8 seconds, which on a large farm is many reads a
+# second. A saved change applies at once through invalidate_settings().
+_SETTINGS_TTL_SECONDS = 5.0
+_MODES = frozenset({"off", "all", "selected"})
+
+# A light we switched on can still read as off until the printer reports back.
+# Within this window we trust our own command instead of sending another.
+_REPORT_LAG_SECONDS = 5.0
+
+_holders: dict[int, int] = {}
+_lit_at: dict[int, float] = {}  # printers whose light we turned on, and when
+_off_tasks: dict[int, asyncio.Task] = {}
+_settings_cache: tuple[float, str, float] | None = None  # (read at, mode, delay)
+
+
+async def _read_settings() -> tuple[str, float]:
+    from backend.app.api.routes.settings import get_setting
+    from backend.app.core import database
+
+    async with database.async_session() as db:
+        mode = await get_setting(db, "camera_light_mode")
+        delay = await get_setting(db, "camera_light_delay")
+    mode = mode if mode in _MODES else "off"
+    try:
+        seconds = float(delay) if delay is not None else 2.0
+    except ValueError:
+        seconds = 2.0
+    return mode, min(max(0.0, seconds), MAX_DELAY_SECONDS)
+
+
+async def _light_settings() -> tuple[str, float]:
+    """The light mode and snapshot delay, cached briefly. Off when unreadable."""
+    global _settings_cache
+    now = time.monotonic()
+    if _settings_cache is not None and now - _settings_cache[0] < _SETTINGS_TTL_SECONDS:
+        return _settings_cache[1], _settings_cache[2]
+    try:
+        mode, delay = await _read_settings()
+    except Exception as e:
+        # The light must never cost the picture, and an unreadable setting
+        # must not switch lights it was never asked to switch.
+        logger.debug("[CAMERA-LIGHT] Could not read the light settings: %s", e)
+        return "off", 2.0
+    _settings_cache = (now, mode, delay)
+    return mode, delay
+
+
+def invalidate_settings() -> None:
+    """Make the next camera use read the light settings again."""
+    global _settings_cache
+    _settings_cache = None
+
+
+def _get_client(printer_id: int):
+    from backend.app.services.printer_manager import printer_manager
+
+    return printer_manager.get_client(printer_id)
+
+
+def _cancel_off(printer_id: int) -> None:
+    task = _off_tasks.pop(printer_id, None)
+    if task is not None and not task.done():
+        task.cancel()
+
+
+def _acquire(printer_id: int) -> float | None:
+    """Hold the light. Returns when it was turned on, or None if it was already on.
+
+    The hold is counted before anything can fail, so the caller always releases.
+    """
+    _holders[printer_id] = _holders.get(printer_id, 0) + 1
+    _cancel_off(printer_id)
+
+    client = _get_client(printer_id)
+    if client is None or client.state is None or client.state.chamber_light:
+        return None
+
+    lit_at = _lit_at.get(printer_id)
+    if lit_at is not None and time.monotonic() - lit_at < _REPORT_LAG_SECONDS:
+        return lit_at  # our "on" hasn't been reported yet
+
+    if not client.set_chamber_light(True):
+        return None
+    logger.info("[CAMERA-LIGHT] Turned on chamber light for printer %s", printer_id)
+    now = time.monotonic()
+    _lit_at[printer_id] = now
+    return now
+
+
+def _release(printer_id: int) -> None:
+    count = _holders.get(printer_id, 0) - 1
+    if count > 0:
+        _holders[printer_id] = count
+        return
+    _holders.pop(printer_id, None)
+    if printer_id in _lit_at:
+        _cancel_off(printer_id)
+        try:
+            _off_tasks[printer_id] = asyncio.create_task(_turn_off_later(printer_id))
+        except RuntimeError:
+            # No running loop: a stream finalised during shutdown. Nothing can
+            # switch the light any more.
+            pass
+
+
+async def _turn_off_later(printer_id: int) -> None:
+    try:
+        await asyncio.sleep(OFF_GRACE_SECONDS)
+    except asyncio.CancelledError:
+        return
+    _off_tasks.pop(printer_id, None)
+    if _holders.get(printer_id) or _lit_at.pop(printer_id, None) is None:
+        return
+    client = _get_client(printer_id)
+    if client is None:
+        return
+    try:
+        if client.set_chamber_light(False):
+            logger.info("[CAMERA-LIGHT] Turned chamber light back off for printer %s", printer_id)
+    except Exception as e:
+        logger.warning("[CAMERA-LIGHT] Failed to turn chamber light off for printer %s: %s", printer_id, e)
+
+
+def hand_back(printer_id: int) -> None:
+    """The user switched the light: it is theirs now, so never turn it off."""
+    _lit_at.pop(printer_id, None)
+    _cancel_off(printer_id)
+
+
+@asynccontextmanager
+async def camera_light(printer, *, wait: bool = True) -> AsyncIterator[None]:
+    """Keep the printer's chamber light on while the camera is in use.
+
+    ``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
+    reach the picture. A live view doesn't wait.
+    """
+    if printer is None:
+        yield
+        return
+    mode, delay = await _light_settings()
+    if mode == "off" or (mode == "selected" and getattr(printer, "camera_light_auto", False) is not True):
+        yield
+        return
+
+    printer_id = printer.id
+    try:
+        try:
+            lit_at = _acquire(printer_id)
+        except Exception as e:
+            # A light we can't switch must never cost the picture.
+            logger.warning("[CAMERA-LIGHT] Failed to turn chamber light on for printer %s: %s", printer_id, e)
+            lit_at = None
+        if wait and lit_at is not None:
+            remaining = delay - (time.monotonic() - lit_at)
+            if remaining > 0:
+                await asyncio.sleep(remaining)
+        yield
+    finally:
+        _release(printer_id)

+ 225 - 0
backend/tests/integration/test_camera_light_api.py

@@ -0,0 +1,225 @@
+"""Chamber light for the camera (#1655): the settings, and the routes that hold the light.
+
+The light mode and delay are set through the settings API here, so the service
+reads them back from the database exactly as it does in production."""
+
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services import camera_light as light_svc
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+@pytest.fixture
+def light_client(monkeypatch):
+    client = MagicMock()
+    client.state = SimpleNamespace(chamber_light=False)
+    client.set_chamber_light.return_value = True
+    monkeypatch.setattr(light_svc, "_get_client", lambda _pid: client)
+    monkeypatch.setattr(light_svc, "_holders", {})
+    monkeypatch.setattr(light_svc, "_lit_at", {})
+    monkeypatch.setattr(light_svc, "_settings_cache", None)
+    off_tasks: dict = {}
+    monkeypatch.setattr(light_svc, "_off_tasks", off_tasks)
+    yield client
+    for task in off_tasks.values():
+        task.cancel()
+
+
+async def _set_light(async_client: AsyncClient, **values):
+    response = await async_client.patch("/api/v1/settings/", json=values)
+    assert response.status_code == 200, response.text
+    return response.json()
+
+
+async def test_the_light_is_off_by_default(async_client: AsyncClient, printer_factory):
+    printer = await printer_factory()
+    settings = (await async_client.get("/api/v1/settings/")).json()
+    assert settings["camera_light_mode"] == "off"
+    assert settings["camera_light_delay"] == 2.0
+    assert (await async_client.get(f"/api/v1/printers/{printer.id}")).json()["camera_light_auto"] is False
+
+
+async def test_the_mode_and_delay_are_saved(async_client: AsyncClient):
+    saved = await _set_light(async_client, camera_light_mode="selected", camera_light_delay=1.5)
+    assert saved["camera_light_mode"] == "selected"
+    assert saved["camera_light_delay"] == 1.5
+
+
+async def test_a_printer_is_picked_for_the_selected_mode(async_client: AsyncClient, printer_factory):
+    printer = await printer_factory()
+    response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"camera_light_auto": True})
+    assert response.status_code == 200
+    assert response.json()["camera_light_auto"] is True
+
+
+@pytest.mark.parametrize(
+    "values",
+    [
+        {"camera_light_mode": "sometimes"},
+        {"camera_light_delay": -1},
+        {"camera_light_delay": 5.5},
+        {"camera_light_delay": None},
+    ],
+)
+async def test_bad_light_settings_are_refused(async_client: AsyncClient, values):
+    response = await async_client.patch("/api/v1/settings/", json=values)
+    assert response.status_code == 422
+
+
+async def test_a_snapshot_is_taken_with_the_light_on(async_client: AsyncClient, printer_factory, light_client):
+    """Home Assistant and other automations take their pictures here."""
+    await _set_light(async_client, camera_light_mode="all", camera_light_delay=0)
+    printer = await printer_factory()
+    fake_jpeg = b"\xff\xd8\xff\xe0snap"
+    seen = {}
+
+    async def _snapshot(printer_id, _printer):
+        seen["calls"] = list(light_client.set_chamber_light.call_args_list)
+        from fastapi import Response
+
+        return Response(content=fake_jpeg, media_type="image/jpeg")
+
+    with patch("backend.app.api.routes.camera._snapshot_response", _snapshot):
+        response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
+
+    assert response.status_code == 200
+    assert response.content == fake_jpeg
+    assert [c.args for c in seen["calls"]] == [(True,)]
+
+
+async def test_a_snapshot_leaves_the_light_alone_by_default(async_client: AsyncClient, printer_factory, light_client):
+    printer = await printer_factory()
+
+    async def _snapshot(printer_id, _printer):
+        from fastapi import Response
+
+        return Response(content=b"\xff\xd8", media_type="image/jpeg")
+
+    with patch("backend.app.api.routes.camera._snapshot_response", _snapshot):
+        await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
+
+    light_client.set_chamber_light.assert_not_called()
+
+
+async def test_selected_mode_lights_only_the_picked_printers(async_client: AsyncClient, printer_factory, light_client):
+    await _set_light(async_client, camera_light_mode="selected", camera_light_delay=0)
+    picked = await printer_factory(camera_light_auto=True)
+    other = await printer_factory(serial_number="00M09A999999999")
+
+    async def _snapshot(printer_id, _printer):
+        from fastapi import Response
+
+        return Response(content=b"\xff\xd8", media_type="image/jpeg")
+
+    with patch("backend.app.api.routes.camera._snapshot_response", _snapshot):
+        await async_client.get(f"/api/v1/printers/{other.id}/camera/snapshot")
+        light_client.set_chamber_light.assert_not_called()
+        await async_client.get(f"/api/v1/printers/{picked.id}/camera/snapshot")
+    light_client.set_chamber_light.assert_called_once_with(True)
+
+
+async def test_saving_the_mode_applies_at_once(async_client: AsyncClient, printer_factory, light_client):
+    """The service caches the settings; saving them must not wait out the cache."""
+    printer = await printer_factory()
+
+    async def _snapshot(printer_id, _printer):
+        from fastapi import Response
+
+        return Response(content=b"\xff\xd8", media_type="image/jpeg")
+
+    with patch("backend.app.api.routes.camera._snapshot_response", _snapshot):
+        await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")  # caches "off"
+        await _set_light(async_client, camera_light_mode="all", camera_light_delay=0)
+        await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
+    light_client.set_chamber_light.assert_called_once_with(True)
+
+
+async def test_switching_the_light_by_hand_hands_it_back(async_client: AsyncClient, printer_factory):
+    printer = await printer_factory()
+    light_svc._lit_at[printer.id] = 0.0
+    client = MagicMock()
+    client.set_chamber_light.return_value = True
+    try:
+        with patch("backend.app.api.routes.printers.printer_manager") as pm:
+            pm.get_client.return_value = client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
+        assert response.status_code == 200
+        assert printer.id not in light_svc._lit_at
+    finally:
+        light_svc._lit_at.pop(printer.id, None)
+
+
+async def test_the_live_view_holds_the_light_while_it_streams(async_client: AsyncClient, printer_factory, light_client):
+    await _set_light(async_client, camera_light_mode="all", camera_light_delay=5)
+    printer = await printer_factory()
+    during = {}
+
+    async def _iter_subscriber(*_args, **_kwargs):
+        during["calls"] = [c.args for c in light_client.set_chamber_light.call_args_list]
+        yield b"--frame\r\n"
+
+    broadcaster = MagicMock(subscriber_count=1)
+
+    async def _subscribe():
+        return MagicMock()
+
+    broadcaster.subscribe = _subscribe
+
+    async def _get_broadcaster(_key, _factory):
+        return broadcaster
+
+    with (
+        patch("backend.app.api.routes.camera.get_or_create_broadcaster", _get_broadcaster),
+        patch("backend.app.api.routes.camera.iter_subscriber", _iter_subscriber),
+    ):
+        response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/stream")
+
+    assert response.status_code == 200
+    assert during["calls"] == [(True,)]
+    # The viewer left: the light goes off after the grace, not at once.
+    assert printer.id in light_svc._off_tasks
+
+
+async def test_an_external_camera_view_holds_the_light_too(async_client: AsyncClient, printer_factory, light_client):
+    await _set_light(async_client, camera_light_mode="selected")
+    printer = await printer_factory(
+        camera_light_auto=True,
+        external_camera_enabled=True,
+        external_camera_url="http://192.0.2.50/mjpeg",
+        external_camera_type="mjpeg",
+    )
+    during = {}
+
+    async def _stream(*_args, **_kwargs):
+        during["calls"] = [c.args for c in light_client.set_chamber_light.call_args_list]
+        yield b"--frame\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
+    assert during["calls"] == [(True,)]
+    assert printer.id in light_svc._off_tasks
+
+
+async def test_a_bad_stored_value_never_blocks_saving_settings(async_client: AsyncClient, db_session):
+    """The settings page sends every field back on each save. A stored mode or
+    delay the update schema refuses would otherwise fail every later save."""
+    from backend.app.api.routes.settings import set_setting
+
+    await set_setting(db_session, "camera_light_mode", "sometimes")
+    await set_setting(db_session, "camera_light_delay", "30")
+    await db_session.commit()
+
+    settings = (await async_client.get("/api/v1/settings/")).json()
+    assert settings["camera_light_mode"] == "off"
+    assert settings["camera_light_delay"] == 5.0
+
+    resend = {**settings, "auto_archive": not settings["auto_archive"]}
+    response = await async_client.put("/api/v1/settings/", json=resend)
+    assert response.status_code == 200, response.text

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

@@ -0,0 +1,309 @@
+"""Chamber light for the camera (#1655).
+
+The light goes on for a camera use when the light mode covers the printer, stays on
+while anything still uses the camera, and goes off after a grace only if
+Bambuddy turned it on.
+"""
+
+import asyncio
+import time
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+
+from backend.app.services import camera_light as svc
+from backend.app.services.camera_light import camera_light
+
+pytestmark = pytest.mark.unit
+
+# The autouse fixture stubs the settings read; this is the real one.
+_real_read_settings = svc._read_settings
+
+GRACE = 0.05
+
+
+class LightSettings:
+    """Stands in for reading the two app settings; counts the reads."""
+
+    def __init__(self):
+        self.mode = "all"
+        self.delay = 0.0
+        self.reads = 0
+
+    async def __call__(self):
+        self.reads += 1
+        return self.mode, self.delay
+
+
+@pytest.fixture(autouse=True)
+def settings(monkeypatch):
+    monkeypatch.setattr(svc, "_holders", {})
+    monkeypatch.setattr(svc, "_lit_at", {})
+    off_tasks: dict = {}
+    monkeypatch.setattr(svc, "_off_tasks", off_tasks)
+    monkeypatch.setattr(svc, "OFF_GRACE_SECONDS", GRACE)
+    light = LightSettings()
+    monkeypatch.setattr(svc, "_read_settings", light)
+    monkeypatch.setattr(svc, "_settings_cache", None)
+    yield light
+    for task in off_tasks.values():
+        task.cancel()
+
+
+class FakeClient:
+    """A printer whose light state follows its commands, like a real report would."""
+
+    def __init__(self, light_on: bool = False, reports: bool = True):
+        self.state = SimpleNamespace(chamber_light=light_on)
+        self.calls: list[bool] = []
+        self._reports = reports
+
+    def set_chamber_light(self, on: bool) -> bool:
+        self.calls.append(on)
+        if self._reports:
+            self.state.chamber_light = on
+        return True
+
+
+def _printer(selected=False, printer_id=1):
+    return SimpleNamespace(id=printer_id, camera_light_auto=selected)
+
+
+@pytest.fixture
+def client(monkeypatch):
+    fake = FakeClient()
+    monkeypatch.setattr(svc, "_get_client", lambda _pid: fake)
+    return fake
+
+
+async def _past_grace():
+    await asyncio.sleep(GRACE * 3)
+
+
+class TestWhichPrinters:
+    @pytest.mark.asyncio
+    async def test_mode_off_never_switches(self, client, settings):
+        settings.mode = "off"
+        async with camera_light(_printer(selected=True)):
+            pass
+        await _past_grace()
+        assert client.calls == []
+
+    @pytest.mark.asyncio
+    async def test_mode_all_switches_every_printer(self, client, settings):
+        settings.mode = "all"
+        async with camera_light(_printer(selected=False)):
+            pass
+        assert client.calls == [True]
+
+    @pytest.mark.asyncio
+    async def test_mode_selected_switches_only_picked_printers(self, client, settings):
+        settings.mode = "selected"
+        async with camera_light(_printer(selected=False)):
+            pass
+        assert client.calls == []
+        async with camera_light(_printer(selected=True)):
+            pass
+        assert client.calls == [True]
+
+    @pytest.mark.asyncio
+    async def test_no_printer_is_a_no_op(self, client):
+        async with camera_light(None):
+            pass
+        assert client.calls == []
+
+
+class TestTurnsOnAndOff:
+    @pytest.mark.asyncio
+    async def test_a_dark_printer_is_lit_for_the_picture_and_turned_off_after(self, client):
+        async with camera_light(_printer()):
+            assert client.calls == [True]
+        assert client.calls == [True]  # not yet: the grace runs first
+        await _past_grace()
+        assert client.calls == [True, False]
+
+    @pytest.mark.asyncio
+    async def test_a_light_that_was_already_on_is_left_alone(self, client, settings):
+        client.state.chamber_light = True
+        settings.delay = 5
+        async with camera_light(_printer()):
+            pass
+        await _past_grace()
+        assert client.calls == []
+
+    @pytest.mark.asyncio
+    async def test_the_light_stays_on_until_the_last_user_lets_go(self, client):
+        printer = _printer()
+        viewer = camera_light(printer, wait=False)
+        await viewer.__aenter__()
+        async with camera_light(printer):
+            pass
+        await _past_grace()
+        assert client.calls == [True]  # the viewer still watches
+        await viewer.__aexit__(None, None, None)
+        await _past_grace()
+        assert client.calls == [True, False]
+
+    @pytest.mark.asyncio
+    async def test_coming_back_within_the_grace_keeps_the_light_on(self, client):
+        # The camera wall polls each tile every 8 seconds; it must not flash.
+        printer = _printer()
+        async with camera_light(printer):
+            pass
+        async with camera_light(printer):
+            pass
+        assert client.calls == [True]
+        await _past_grace()
+        assert client.calls == [True, False]
+
+
+class TestTheDelay:
+    @pytest.mark.asyncio
+    async def test_a_snapshot_waits_after_turning_the_light_on(self, client, settings):
+        settings.delay = 0.2
+        start = time.monotonic()
+        async with camera_light(_printer()):
+            waited = time.monotonic() - start
+        assert waited >= 0.2
+
+    @pytest.mark.asyncio
+    async def test_a_live_view_does_not_wait_either_way(self, client, settings):
+        settings.delay = 5
+        start = time.monotonic()
+        async with camera_light(_printer(), wait=False):
+            waited = time.monotonic() - start
+        assert waited < 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.asyncio
+    async def test_a_second_snapshot_waits_only_for_what_is_left_and_sends_nothing(self, monkeypatch, settings):
+        # The printer hasn't reported the light yet: the second picture trusts
+        # our own command instead of sending another, and still waits for it.
+        slow = FakeClient(reports=False)
+        monkeypatch.setattr(svc, "_get_client", lambda _pid: slow)
+        settings.delay = 0.3
+        printer = _printer()
+        first = asyncio.create_task(_hold(printer))
+        await asyncio.sleep(0.1)
+        start = time.monotonic()
+        async with camera_light(printer):
+            waited = time.monotonic() - start
+        await first
+        assert slow.calls == [True]
+        assert 0.1 <= waited < 0.3
+
+
+async def _hold(printer):
+    async with camera_light(printer):
+        pass
+
+
+class TestTheUserDecides:
+    @pytest.mark.asyncio
+    async def test_switching_the_light_by_hand_keeps_it_on(self, client):
+        printer = _printer()
+        async with camera_light(printer):
+            svc.hand_back(printer.id)
+        await _past_grace()
+        assert client.calls == [True]
+
+    @pytest.mark.asyncio
+    async def test_a_light_switched_off_at_the_printer_comes_back_for_the_next_picture(self, client, monkeypatch):
+        monkeypatch.setattr(svc, "_REPORT_LAG_SECONDS", 0.05)
+        printer = _printer()
+        viewer = camera_light(printer, wait=False)
+        await viewer.__aenter__()
+        client.state.chamber_light = False  # switched off on the printer's screen
+        await asyncio.sleep(0.1)
+        async with camera_light(printer):
+            pass
+        await viewer.__aexit__(None, None, None)
+        await _past_grace()
+        assert client.calls == [True, True, False]
+
+
+class TestFailuresNeverCostThePicture:
+    @pytest.mark.asyncio
+    async def test_a_disconnected_printer_still_gets_its_capture(self, monkeypatch, settings):
+        monkeypatch.setattr(svc, "_get_client", lambda _pid: None)
+        settings.delay = 5
+        ran = False
+        async with camera_light(_printer()):
+            ran = True
+        assert ran
+        assert svc._holders == {}
+
+    @pytest.mark.asyncio
+    async def test_a_failing_light_command_still_gets_its_capture(self, monkeypatch, settings):
+        broken = MagicMock()
+        broken.state = SimpleNamespace(chamber_light=False)
+        broken.set_chamber_light.side_effect = RuntimeError("mqtt down")
+        monkeypatch.setattr(svc, "_get_client", lambda _pid: broken)
+        settings.delay = 5
+        ran = False
+        async with camera_light(_printer()):
+            ran = True
+        assert ran
+        assert svc._holders == {}
+
+    @pytest.mark.asyncio
+    async def test_a_capture_that_raises_still_lets_go_of_the_light(self, client):
+        with pytest.raises(ValueError):
+            async with camera_light(_printer()):
+                raise ValueError("capture failed")
+        await _past_grace()
+        assert client.calls == [True, False]
+        assert svc._holders == {}
+
+
+class TestTheSettings:
+    @pytest.mark.asyncio
+    async def test_settings_are_read_once_then_cached_until_saved(self, client, settings):
+        async with camera_light(_printer()):
+            pass
+        async with camera_light(_printer()):
+            pass
+        assert settings.reads == 1
+        svc.invalidate_settings()
+        async with camera_light(_printer()):
+            pass
+        assert settings.reads == 2
+
+    @pytest.mark.asyncio
+    async def test_unreadable_settings_switch_nothing(self, client, monkeypatch):
+        async def _broken():
+            raise RuntimeError("database gone")
+
+        monkeypatch.setattr(svc, "_read_settings", _broken)
+        async with camera_light(_printer(selected=True)):
+            pass
+        assert client.calls == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        ("stored_mode", "stored_delay", "expected"),
+        [
+            ("all", "1.5", ("all", 1.5)),
+            ("selected", None, ("selected", 2.0)),
+            (None, None, ("off", 2.0)),  # never saved
+            ("sometimes", "x", ("off", 2.0)),  # garbage reads as off
+            ("all", "30", ("all", 5.0)),  # never past the finish photo's budget
+            ("all", "-3", ("all", 0.0)),
+        ],
+    )
+    async def test_stored_values_are_parsed_safely(self, monkeypatch, stored_mode, stored_delay, expected):
+        from contextlib import asynccontextmanager
+
+        stored = {"camera_light_mode": stored_mode, "camera_light_delay": stored_delay}
+
+        async def _get_setting(_db, key):
+            return stored[key]
+
+        @asynccontextmanager
+        async def _session():
+            yield object()
+
+        monkeypatch.setattr("backend.app.api.routes.settings.get_setting", _get_setting)
+        monkeypatch.setattr("backend.app.core.database.async_session", _session)
+        assert await _real_read_settings() == expected

+ 161 - 2
backend/tests/unit/test_finish_photo_moment_sync.py

@@ -316,10 +316,11 @@ def _bank_env(monkeypatch, *, state="RUNNING", sub_stage=0, total_layers=10, pri
     monkeypatch.setattr(main_module.printer_manager, "get_client", lambda _pid: client)
     monkeypatch.setattr(main_module.printer_manager, "get_client", lambda _pid: client)
     monkeypatch.setattr(main_module, "async_session", lambda: _fake_session(printer))
     monkeypatch.setattr(main_module, "async_session", lambda: _fake_session(printer))
 
 
-    counter = {"n": 0}
+    counter = {"n": 0, "light": []}
 
 
-    async def _capture(_pid, _printer, _logger):
+    async def _capture(_pid, _printer, _logger, *, light=True):
         counter["n"] += 1
         counter["n"] += 1
+        counter["light"].append(light)
         return f"frame-{counter['n']}".encode()
         return f"frame-{counter['n']}".encode()
 
 
     monkeypatch.setattr(main_module, "_capture_snapshot_for_notification", _capture)
     monkeypatch.setattr(main_module, "_capture_snapshot_for_notification", _capture)
@@ -332,6 +333,14 @@ async def test_bank_stores_frame_while_printing(monkeypatch):
     assert main_module._inprint_frame_bank[3] == b"frame-1"
     assert main_module._inprint_frame_bank[3] == b"frame-1"
 
 
 
 
+async def test_bank_leaves_the_chamber_light_alone(monkeypatch):
+    """#1655: the bank grabs a frame every 25 seconds all through a print, so
+    holding the chamber light for it would make the light flash."""
+    counter = _bank_env(monkeypatch)
+    await main_module._maybe_bank_inprint_frame(3, 5)
+    assert counter["light"] == [False]
+
+
 async def test_bank_throttles_within_interval(monkeypatch):
 async def test_bank_throttles_within_interval(monkeypatch):
     counter = _bank_env(monkeypatch)
     counter = _bank_env(monkeypatch)
     await main_module._maybe_bank_inprint_frame(3, 5)  # banks frame-1
     await main_module._maybe_bank_inprint_frame(3, 5)  # banks frame-1
@@ -729,6 +738,18 @@ async def test_producer_wait_budget_covers_the_restore():
     assert main_module._FINISH_PHOTO_PRODUCER_WAIT_SECONDS > main_module._PLATE_RESTORE_SETTLE_SECONDS + 15
     assert main_module._FINISH_PHOTO_PRODUCER_WAIT_SECONDS > main_module._PLATE_RESTORE_SETTLE_SECONDS + 15
 
 
 
 
+def test_producer_wait_budget_covers_the_chamber_light_delay():
+    """#1655: the producer's live grab can first wait for the chamber light.
+    Settle + the longest light delay + a worst-case grab must still fit, or the
+    consumer gives up and its fallback grab races the producer's (#1790)."""
+    from backend.app.services.camera_light import MAX_DELAY_SECONDS
+
+    assert (
+        main_module._FINISH_PHOTO_PRODUCER_WAIT_SECONDS
+        > main_module._PLATE_RESTORE_SETTLE_SECONDS + MAX_DELAY_SECONDS + 15
+    )
+
+
 class TestMaxZResolution:
 class TestMaxZResolution:
     """#2547 safety: the height that becomes a Z-move target must provably
     """#2547 safety: the height that becomes a Z-move target must provably
     belong to the print that just finished.
     belong to the print that just finished.
@@ -873,3 +894,141 @@ class TestTimelapsePathPlateRestore:
         await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state", "timelapse_was_active": True})
         await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state", "timelapse_was_active": True})
 
 
         assert moved == []
         assert moved == []
+
+
+# --- #1655 chamber light for the camera ------------------------------------
+
+
+class _LightClient:
+    def __init__(self):
+        self.state = SimpleNamespace(chamber_light=False)
+        self.calls: list[bool] = []
+        self.mode = "off"  # the camera_light_mode setting
+
+    def set_chamber_light(self, on):
+        self.calls.append(on)
+        self.state.chamber_light = on
+        return True
+
+
+@pytest.fixture
+def light_client(monkeypatch):
+    from backend.app.services import camera_light as light_svc
+
+    client = _LightClient()
+    monkeypatch.setattr(light_svc, "_get_client", lambda _pid: client)
+
+    async def _settings():
+        return client.mode, 0.0
+
+    monkeypatch.setattr(light_svc, "_read_settings", _settings)
+    monkeypatch.setattr(light_svc, "_settings_cache", None)
+    monkeypatch.setattr(light_svc, "_holders", {})
+    monkeypatch.setattr(light_svc, "_lit_at", {})
+    off_tasks: dict = {}
+    monkeypatch.setattr(light_svc, "_off_tasks", off_tasks)
+    yield client
+    for task in off_tasks.values():
+        task.cancel()
+
+
+async def test_finish_photo_live_grab_is_lit_when_the_light_mode_covers_it(patched_env, monkeypatch, light_client):
+    light_client.mode = "all"
+    seen = {}
+
+    async def _live(**_kwargs):
+        seen["light"] = light_client.state.chamber_light
+        return b"\xff\xd8live"
+
+    monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live)
+
+    await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
+
+    assert seen["light"] is True
+    assert light_client.calls == [True]  # off follows after the grace
+
+
+async def test_finish_photo_live_grab_leaves_the_light_alone_by_default(patched_env, monkeypatch, light_client):
+    async def _live(**_kwargs):
+        return b"\xff\xd8live"
+
+    monkeypatch.setattr("backend.app.services.camera.capture_camera_frame_bytes", _live)
+
+    await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
+
+    assert light_client.calls == []
+
+
+async def test_finish_photo_from_the_bank_does_not_turn_the_light_on(patched_env, monkeypatch, light_client):
+    """The banked frame is already taken; lighting the printer for it would
+    only switch the light on and off for nothing."""
+    light_client.mode = "all"
+    print_dispatch_context.mark_pending(patched_env.id)
+    print_dispatch_context.adopt(patched_env.id)
+    main_module._inprint_frame_bank[patched_env.id] = b"\xff\xd8banked"
+
+    await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
+
+    assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8banked"
+    assert light_client.calls == []
+
+
+async def test_notification_snapshot_is_lit_for_a_selected_printer(fake_printer, monkeypatch, light_client):
+    light_client.mode = "selected"
+    fake_printer.camera_light_auto = True
+    seen = {}
+
+    async def _frame(_pid, _printer, _logger):
+        seen["light"] = light_client.state.chamber_light
+        return b"\xff\xd8snap"
+
+    monkeypatch.setattr(main_module, "_capture_snapshot_frame", _frame)
+
+    assert await main_module._capture_snapshot_for_notification(7, fake_printer, logging.getLogger()) == b"\xff\xd8snap"
+    assert seen["light"] is True
+    assert light_client.calls == [True]
+
+
+def test_every_live_capture_in_main_holds_the_light():
+    """#1655: the finish photo's last fallback lives in `_background_finish_photo`,
+    a closure nothing can drive directly (see the rotation guard above). So the
+    source is checked: every live camera grab in main.py runs inside
+    `camera_light(...)`, except in `_capture_snapshot_frame`, whose only caller
+    is the wrapper that holds the light for it.
+    """
+    import ast
+    from pathlib import Path
+
+    main_py = Path(__file__).resolve().parents[2] / "app" / "main.py"
+    tree = ast.parse(main_py.read_text())
+    parents = {child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node)}
+    grabs = {"capture_frame", "capture_camera_frame", "capture_camera_frame_bytes", "capture_finish_photo"}
+
+    def _held(node) -> bool:
+        while node in parents:
+            node = parents[node]
+            if isinstance(node, ast.AsyncFunctionDef) and node.name == "_capture_snapshot_frame":
+                return True
+            if isinstance(node, ast.AsyncWith) and any(
+                isinstance(item.context_expr, ast.Call)
+                and getattr(item.context_expr.func, "id", None) == "camera_light"
+                for item in node.items
+            ):
+                return True
+        return False
+
+    calls = [
+        node
+        for node in ast.walk(tree)
+        if isinstance(node, ast.Call) and (getattr(node.func, "id", None) or getattr(node.func, "attr", None)) in grabs
+    ]
+    assert len(calls) >= 6, "guard found fewer grabs than main.py has; is it parsing the right file?"
+    unlit = [node.lineno for node in calls if not _held(node)]
+    assert not unlit, f"main.py:{unlit} grabs a camera frame without camera_light(...)"
+
+    wrapper_callers = [
+        node.lineno
+        for node in ast.walk(tree)
+        if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "_capture_snapshot_frame"
+    ]
+    assert len(wrapper_callers) == 1, f"_capture_snapshot_frame is called from main.py:{wrapper_callers}"

+ 226 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -1622,12 +1622,18 @@ describe('SettingsPage', () => {
       window.history.replaceState({}, '', '/?tab=camera');
       window.history.replaceState({}, '', '/?tab=camera');
     });
     });
 
 
+    // A camera's fields stay folded until its row is opened.
+    const openCameraFields = async () => {
+      await userEvent.setup().click(await screen.findByLabelText('Camera settings: go2rtc Cam'));
+    };
+
     it('renders the snapshot URL input when camera_type is mjpeg', async () => {
     it('renders the snapshot URL input when camera_type is mjpeg', async () => {
       server.use(
       server.use(
         http.get('/api/v1/printers/', () => HttpResponse.json([mjpegPrinter])),
         http.get('/api/v1/printers/', () => HttpResponse.json([mjpegPrinter])),
       );
       );
 
 
       render(<SettingsPage />);
       render(<SettingsPage />);
+      await openCameraFields();
 
 
       await waitFor(() => {
       await waitFor(() => {
         expect(screen.getByPlaceholderText(/api\/frame\.jpeg\?src=printer/)).toBeInTheDocument();
         expect(screen.getByPlaceholderText(/api\/frame\.jpeg\?src=printer/)).toBeInTheDocument();
@@ -1642,6 +1648,7 @@ describe('SettingsPage', () => {
       );
       );
 
 
       render(<SettingsPage />);
       render(<SettingsPage />);
+      await openCameraFields();
 
 
       // Wait for the live-stream URL placeholder to render so we know the
       // Wait for the live-stream URL placeholder to render so we know the
       // camera section finished mounting before asserting absence of the
       // camera section finished mounting before asserting absence of the
@@ -1665,6 +1672,7 @@ describe('SettingsPage', () => {
         );
         );
 
 
         render(<SettingsPage />);
         render(<SettingsPage />);
+        await openCameraFields();
 
 
         const input = await waitFor(() =>
         const input = await waitFor(() =>
           screen.getByPlaceholderText(/api\/frame\.jpeg\?src=printer/),
           screen.getByPlaceholderText(/api\/frame\.jpeg\?src=printer/),
@@ -1693,6 +1701,224 @@ describe('SettingsPage', () => {
     );
     );
   });
   });
 
 
+  describe('chamber light for the camera (#1655)', () => {
+    const base = {
+      serial_number: 'S',
+      ip_address: '192.168.1.100',
+      access_code: 'XXXX',
+      model: 'P1S',
+      location: null,
+      nozzle_count: 1,
+      is_active: true,
+      auto_archive: true,
+      external_camera_url: null,
+      external_camera_type: null,
+      external_camera_enabled: false,
+      external_camera_snapshot_url: null,
+      camera_rotation: 0,
+      camera_light_auto: false,
+      plate_detection_enabled: false,
+      created_at: '2026-01-01T00:00:00Z',
+      updated_at: '2026-01-01T00:00:00Z',
+    };
+    const farm = (n: number) =>
+      Array.from({ length: n }, (_, i) => ({ ...base, id: 100 + i, name: `Farm ${i + 1}`, serial_number: `S${i}` }));
+
+    const withSettings = (overrides: Record<string, unknown>) =>
+      http.get('/api/v1/settings/', () => HttpResponse.json({ ...mockSettings, ...overrides }));
+
+    // The page ignores edits for 100ms after the settings load.
+    const settle = () => new Promise(resolve => setTimeout(resolve, 200));
+
+    beforeEach(() => {
+      window.history.replaceState({}, '', '/?tab=camera');
+    });
+
+    it('is off by default and lists no printers, however many there are', async () => {
+      server.use(http.get('/api/v1/printers/', () => HttpResponse.json(farm(120))));
+
+      render(<SettingsPage />);
+
+      expect(await screen.findByRole('radio', { name: 'Off' })).toBeChecked();
+      expect(screen.queryByLabelText('Wait before snapshot')).toBeNull();
+      expect(screen.queryByTestId('camera-light-printers')).toBeNull();
+      expect(within(document.getElementById('card-camera')!).queryByText('Farm 77')).toBeNull();
+    });
+
+    it('switches every printer on with one choice and saves it', async () => {
+      let saved: Record<string, unknown> | null = null;
+      server.use(
+        http.put('/api/v1/settings/', async ({ request }) => {
+          saved = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ ...mockSettings, ...saved });
+        }),
+      );
+
+      render(<SettingsPage />);
+      const all = await screen.findByRole('radio', { name: 'All printers' });
+      await settle();
+      await userEvent.click(all);
+
+      await waitFor(() => expect(saved?.camera_light_mode).toBe('all'), { timeout: 3000 });
+      expect(screen.getByLabelText('Wait before snapshot')).toHaveValue(2);
+      expect(screen.queryByTestId('camera-light-printers')).toBeNull();
+    });
+
+    it('saves one delay for all printers, never one the server would refuse', async () => {
+      const saves: Record<string, unknown>[] = [];
+      server.use(
+        withSettings({ camera_light_mode: 'all', camera_light_delay: 2 }),
+        http.put('/api/v1/settings/', async ({ request }) => {
+          const body = (await request.json()) as Record<string, unknown>;
+          saves.push(body);
+          return HttpResponse.json({ ...mockSettings, camera_light_mode: 'all', ...body });
+        }),
+      );
+
+      render(<SettingsPage />);
+      const input = await screen.findByLabelText('Wait before snapshot');
+      await settle();
+      const user = userEvent.setup();
+      await user.clear(input);
+      await user.type(input, '9');
+      await new Promise(resolve => setTimeout(resolve, 1000));
+      expect(saves.some(b => b.camera_light_delay === 9)).toBe(false);
+
+      await user.clear(input);
+      await user.type(input, '1.5');
+      await waitFor(() => expect(saves.some(b => b.camera_light_delay === 1.5)).toBe(true), { timeout: 3000 });
+    }, 10_000);
+
+    it('picks printers for the selected mode from a searchable list and removes them again', async () => {
+      const printers = farm(120);
+      printers[41] = { ...printers[41], camera_light_auto: true };
+      const patches: { id: number; body: Record<string, unknown> }[] = [];
+      server.use(
+        withSettings({ camera_light_mode: 'selected' }),
+        http.get('/api/v1/printers/', () => HttpResponse.json(printers)),
+        http.patch('/api/v1/printers/:id', async ({ params, request }) => {
+          const body = (await request.json()) as Record<string, unknown>;
+          patches.push({ id: Number(params.id), body });
+          return HttpResponse.json({ ...printers[Number(params.id) - 100], ...body });
+        }),
+      );
+
+      render(<SettingsPage />);
+      const chips = await screen.findByTestId('camera-light-printers');
+      expect(within(chips).getByText('Farm 42')).toBeInTheDocument();
+      expect(within(chips).queryByText('Farm 7')).toBeNull();
+
+      const user = userEvent.setup();
+      await user.click(within(chips).getByRole('button', { name: 'Add printer' }));
+      await user.type(screen.getByLabelText('Search printers'), 'farm 7');
+      const options = within(screen.getByRole('listbox', { name: 'Add printer' })).getAllByRole('option');
+      // Farm 7 and Farm 70-79; Farm 42, already picked, is not offered.
+      expect(options).toHaveLength(11);
+      await user.click(within(options[0]).getByRole('button', { name: 'Farm 7' }));
+      await waitFor(() => expect(patches).toContainEqual({ id: 106, body: { camera_light_auto: true } }));
+
+      await user.click(screen.getByRole('button', { name: 'Remove Farm 42' }));
+      await waitFor(() => expect(patches).toContainEqual({ id: 141, body: { camera_light_auto: false } }));
+    });
+  });
+
+  describe('external cameras list only the printers that have one', () => {
+    const cam = (id: number, name: string, extra: Record<string, unknown> = {}) => ({
+      id,
+      name,
+      serial_number: `S${id}`,
+      ip_address: '192.168.1.100',
+      access_code: 'XXXX',
+      model: 'P1S',
+      location: null,
+      nozzle_count: 1,
+      is_active: true,
+      auto_archive: true,
+      external_camera_url: null,
+      external_camera_type: null,
+      external_camera_enabled: false,
+      external_camera_snapshot_url: null,
+      camera_rotation: 0,
+      camera_light_auto: false,
+      plate_detection_enabled: false,
+      created_at: '2026-01-01T00:00:00Z',
+      updated_at: '2026-01-01T00:00:00Z',
+      ...extra,
+    });
+
+    beforeEach(() => {
+      window.history.replaceState({}, '', '/?tab=camera');
+    });
+
+    it('shows a farm\'s two external cameras, not its 120 printers, folded with where each points', async () => {
+      const printers = Array.from({ length: 120 }, (_, i) => cam(100 + i, `Farm ${i + 1}`));
+      printers[4] = cam(104, 'Farm 5', {
+        external_camera_enabled: true,
+        external_camera_type: 'mjpeg',
+        external_camera_url: 'http://192.168.1.60:1984/api/stream.mjpeg',
+      });
+      printers[9] = cam(109, 'Farm 10', {
+        external_camera_enabled: true,
+        external_camera_type: 'rtsp',
+        external_camera_url: 'rtsp://user:pw@192.168.1.61:554/live',
+      });
+      server.use(http.get('/api/v1/printers/', () => HttpResponse.json(printers)));
+
+      render(<SettingsPage />);
+      const list = await screen.findByTestId('external-camera-list');
+
+      expect(within(list).getAllByRole('button', { name: /^Camera settings:/ })).toHaveLength(2);
+      expect(within(list).getByText('MJPEG · 192.168.1.60:1984')).toBeInTheDocument();
+      expect(within(list).getByText('RTSP · 192.168.1.61:554')).toBeInTheDocument();
+      expect(screen.queryByPlaceholderText(/Camera URL/i)).toBeNull();
+
+      const user = userEvent.setup();
+      await user.click(screen.getByLabelText('Camera settings: Farm 5'));
+      expect(screen.getByDisplayValue('http://192.168.1.60:1984/api/stream.mjpeg')).toBeInTheDocument();
+      await user.click(screen.getByLabelText('Camera settings: Farm 10'));
+      expect(screen.getByDisplayValue('rtsp://user:pw@192.168.1.61:554/live')).toBeInTheDocument();
+      expect(screen.queryByDisplayValue('http://192.168.1.60:1984/api/stream.mjpeg')).toBeNull();
+    });
+
+    it('adds an external camera from the printer picker and opens its fields', async () => {
+      const printers = [cam(1, 'Garage X1C'), cam(2, 'Office A1')];
+      let body: Record<string, unknown> | null = null;
+      server.use(
+        http.get('/api/v1/printers/', () => HttpResponse.json(printers)),
+        http.patch('/api/v1/printers/2', async ({ request }) => {
+          body = (await request.json()) as Record<string, unknown>;
+          Object.assign(printers[1], body);
+          return HttpResponse.json(printers[1]);
+        }),
+      );
+
+      render(<SettingsPage />);
+      const user = userEvent.setup();
+      await user.click(await screen.findByRole('button', { name: 'Add external camera' }));
+      await user.click(screen.getByRole('button', { name: 'Office A1' }));
+
+      await waitFor(() => expect(body).toEqual({ external_camera_enabled: true }));
+      expect(await screen.findByPlaceholderText(/Camera URL/i)).toBeInTheDocument();
+    });
+
+    it('removes an external camera', async () => {
+      const printers = [cam(1, 'Garage X1C', { external_camera_enabled: true, external_camera_url: 'http://192.168.1.9/s' })];
+      let body: Record<string, unknown> | null = null;
+      server.use(
+        http.get('/api/v1/printers/', () => HttpResponse.json(printers)),
+        http.patch('/api/v1/printers/1', async ({ request }) => {
+          body = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ ...printers[0], ...body });
+        }),
+      );
+
+      render(<SettingsPage />);
+      await userEvent.click(await screen.findByRole('button', { name: 'Remove the external camera from Garage X1C' }));
+
+      await waitFor(() => expect(body).toEqual({ external_camera_enabled: false }));
+    });
+  });
+
   describe('theme mode buttons', () => {
   describe('theme mode buttons', () => {
     it('renders Dark, Light, and System buttons', async () => {
     it('renders Dark, Light, and System buttons', async () => {
       render(<SettingsPage />);
       render(<SettingsPage />);

+ 5 - 0
frontend/src/api/client.ts

@@ -453,6 +453,7 @@ export interface Printer {
   external_camera_enabled: boolean;
   external_camera_enabled: boolean;
   external_camera_snapshot_url: string | null;  // optional single-frame override (#1177)
   external_camera_snapshot_url: string | null;  // optional single-frame override (#1177)
   camera_rotation: number;  // 0, 90, 180, 270 degrees
   camera_rotation: number;  // 0, 90, 180, 270 degrees
+  camera_light_auto: boolean;  // picked for the chamber light when camera_light_mode is 'selected' (#1655)
   plate_detection_enabled: boolean;  // Check plate before print
   plate_detection_enabled: boolean;  // Check plate before print
   plate_detection_roi?: PlateDetectionROI;  // ROI for plate detection
   plate_detection_roi?: PlateDetectionROI;  // ROI for plate detection
   created_at: string;
   created_at: string;
@@ -759,6 +760,7 @@ export interface PrinterCreate {
   external_camera_enabled?: boolean;
   external_camera_enabled?: boolean;
   external_camera_snapshot_url?: string | null;
   external_camera_snapshot_url?: string | null;
   camera_rotation?: number;
   camera_rotation?: number;
+  camera_light_auto?: boolean;
   plate_detection_enabled?: boolean;
   plate_detection_enabled?: boolean;
   plate_detection_roi?: PlateDetectionROI;
   plate_detection_roi?: PlateDetectionROI;
 }
 }
@@ -1484,6 +1486,9 @@ export interface AppSettings {
   library_disk_warning_gb: number;
   library_disk_warning_gb: number;
   // Camera view settings
   // Camera view settings
   camera_view_mode: 'window' | 'embedded';
   camera_view_mode: 'window' | 'embedded';
+  // Chamber light while the camera is in use (#1655)
+  camera_light_mode: 'off' | 'all' | 'selected';
+  camera_light_delay: number;  // seconds a snapshot waits after the light came on
   // Preferred slicer (server-side API / sidecar)
   // Preferred slicer (server-side API / sidecar)
   preferred_slicer: 'bambu_studio' | 'orcaslicer';
   preferred_slicer: 'bambu_studio' | 'orcaslicer';
   // Desktop "Open in Slicer" override (#1329). Null inherits from
   // Desktop "Open in Slicer" override (#1329). Null inherits from

+ 105 - 0
frontend/src/components/PrinterSearchPicker.tsx

@@ -0,0 +1,105 @@
+import { useEffect, useRef, useState } from 'react';
+import { Plus, Search } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+export interface PickablePrinter {
+  id: number;
+  name: string;
+  location?: string | null;
+}
+
+interface PrinterSearchPickerProps {
+  /** Printers that can still be picked. */
+  printers: PickablePrinter[];
+  onPick: (printer: PickablePrinter) => void;
+  /** Text of the button that opens the picker. */
+  label: string;
+  disabled?: boolean;
+}
+
+/**
+ * A button that opens a searchable list of printers and hands back the one
+ * clicked. Made for farms: with 120 printers a plain select is a long scroll,
+ * so the list filters by name and location as you type.
+ */
+export function PrinterSearchPicker({ printers, onPick, label, disabled }: PrinterSearchPickerProps) {
+  const { t } = useTranslation();
+  const [open, setOpen] = useState(false);
+  const [query, setQuery] = useState('');
+  const rootRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    if (!open) return;
+    const onPointerDown = (e: PointerEvent) => {
+      if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
+    };
+    document.addEventListener('pointerdown', onPointerDown);
+    return () => document.removeEventListener('pointerdown', onPointerDown);
+  }, [open]);
+
+  const needle = query.trim().toLowerCase();
+  const matches = printers.filter(p =>
+    !needle || p.name.toLowerCase().includes(needle) || (p.location ?? '').toLowerCase().includes(needle),
+  );
+
+  const close = () => {
+    setOpen(false);
+    setQuery('');
+  };
+
+  return (
+    <div ref={rootRef} className="relative inline-block">
+      <button
+        type="button"
+        onClick={() => (open ? close() : setOpen(true))}
+        disabled={disabled || printers.length === 0}
+        aria-expanded={open}
+        className="inline-flex items-center gap-1 px-2 py-1 rounded border border-dashed border-bambu-dark-tertiary text-xs text-bambu-gray hover:text-white hover:border-bambu-green disabled:opacity-50 disabled:cursor-not-allowed"
+      >
+        <Plus className="w-3.5 h-3.5" />
+        {label}
+      </button>
+      {open && (
+        <div
+          className="absolute left-0 z-20 mt-1 w-64 max-w-[calc(100vw-2rem)] rounded-lg border border-bambu-dark-tertiary bg-bambu-dark-secondary shadow-lg"
+          onKeyDown={(e) => {
+            if (e.key === 'Escape') close();
+          }}
+        >
+          <div className="relative p-2 border-b border-bambu-dark-tertiary">
+            <Search className="w-3.5 h-3.5 text-bambu-gray absolute left-4 top-1/2 -translate-y-1/2" />
+            <input
+              type="search"
+              autoFocus
+              value={query}
+              onChange={(e) => setQuery(e.target.value)}
+              placeholder={t('settings.printerPickerSearch')}
+              aria-label={t('settings.printerPickerSearch')}
+              className="w-full pl-7 pr-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
+            />
+          </div>
+          <ul className="max-h-60 overflow-y-auto py-1" role="listbox" aria-label={label}>
+            {matches.length === 0 && (
+              <li className="px-3 py-2 text-xs text-bambu-gray italic">{t('settings.printerPickerNoMatch')}</li>
+            )}
+            {matches.map(p => (
+              <li key={p.id} role="option" aria-selected={false}>
+                <button
+                  type="button"
+                  onClick={() => {
+                    onPick(p);
+                    close();
+                  }}
+                  className="w-full text-left px-3 py-1.5 text-sm text-white hover:bg-bambu-dark-tertiary"
+                >
+                  <span className="block truncate">{p.name}</span>
+                  {p.location && <span className="block truncate text-[11px] text-bambu-gray">{p.location}</span>}
+                </button>
+              </li>
+            ))}
+          </ul>
+        </div>
+      )}
+    </div>
+  );
+}

+ 16 - 0
frontend/src/i18n/locales/de.ts

@@ -2824,6 +2824,22 @@ export default {
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlHelp: 'URL für Einzelbildaufnahmen — wird für Benachrichtigungs-Vorschaubilder, Abschlussfotos, Schicht-Zeitraffer und Plattenerkennung verwendet. Zeitraffer und Plattenerkennung benötigen jeweils eigene drucker-spezifische Schalter — diese URL ist nur die Bildquelle, die sie verwenden, wenn sie aktiv sind. Leer lassen, um Bilder aus dem oben konfigurierten Live-Stream zu verwenden. Nützlich für go2rtc (/api/frame.jpeg) und IP-Kameras mit dediziertem Snapshot-Endpunkt.',
     cameraSnapshotUrlHelp: 'URL für Einzelbildaufnahmen — wird für Benachrichtigungs-Vorschaubilder, Abschlussfotos, Schicht-Zeitraffer und Plattenerkennung verwendet. Zeitraffer und Plattenerkennung benötigen jeweils eigene drucker-spezifische Schalter — diese URL ist nur die Bildquelle, die sie verwenden, wenn sie aktiv sind. Leer lassen, um Bilder aus dem oben konfigurierten Live-Stream zu verwenden. Nützlich für go2rtc (/api/frame.jpeg) und IP-Kameras mit dediziertem Snapshot-Endpunkt.',
     cameraRotation: 'Drehung',
     cameraRotation: 'Drehung',
+    cameraLight: 'Kammerbeleuchtung für die Kamera einschalten',
+    cameraLightHint: 'Schaltet das Licht ein, solange jemand die Kamera ansieht, und für automatische Aufnahmen wie Fotos in Benachrichtigungen, Abschlussfotos und Bilder für Home Assistant. Schaltet es danach wieder aus, aber nur, wenn Bambuddy es eingeschaltet hat.',
+    cameraLightDelay: 'Wartezeit vor der Aufnahme',
+    cameraLightDelayHint: 'Nur wenn das Licht aus war. Gibt dem Licht und damit gekoppelten Lampen Zeit zum Angehen.',
+    cameraLightMode_off: 'Aus',
+    cameraLightMode_all: 'Alle Drucker',
+    cameraLightMode_selected: 'Ausgewählte Drucker',
+    cameraLightDelayUnit: 'Sekunden',
+    cameraLightNoneSelected: 'Noch kein Drucker ausgewählt.',
+    cameraLightAddPrinter: 'Drucker hinzufügen',
+    cameraLightRemovePrinter: '{{name}} entfernen',
+    externalCameraAdd: 'Externe Kamera hinzufügen',
+    externalCameraRemove: 'Externe Kamera von {{name}} entfernen',
+    printerPickerSearch: 'Drucker suchen',
+    printerPickerNoMatch: 'Kein Drucker gefunden.',
+    cameraShowSettings: 'Kamera-Einstellungen',
     test: 'Testen',
     test: 'Testen',
     connected: 'Verbunden',
     connected: 'Verbunden',
     disconnected: 'Getrennt',
     disconnected: 'Getrennt',

+ 16 - 0
frontend/src/i18n/locales/en.ts

@@ -2846,6 +2846,22 @@ export default {
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, layer-timelapse frames, and plate detection. Timelapse and plate detection each require their own per-printer toggle — this URL is just the image source they pull from when active. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
     cameraSnapshotUrlHelp: 'Single-frame URL used for notification thumbnails, finish photos, layer-timelapse frames, and plate detection. Timelapse and plate detection each require their own per-printer toggle — this URL is just the image source they pull from when active. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.',
     cameraRotation: 'Rotation',
     cameraRotation: 'Rotation',
+    cameraLight: 'Turn on chamber light for camera',
+    cameraLightHint: 'Turns the light on while someone watches the camera and for automatic snapshots such as notification photos, finish photos and Home Assistant pictures. Turns it off again afterwards, but only if Bambuddy turned it on.',
+    cameraLightDelay: 'Wait before snapshot',
+    cameraLightDelayHint: 'Only when the light was off. Gives the light, and any light synced to it, time to come on.',
+    cameraLightMode_off: 'Off',
+    cameraLightMode_all: 'All printers',
+    cameraLightMode_selected: 'Selected printers',
+    cameraLightDelayUnit: 'seconds',
+    cameraLightNoneSelected: 'No printer picked yet.',
+    cameraLightAddPrinter: 'Add printer',
+    cameraLightRemovePrinter: 'Remove {{name}}',
+    externalCameraAdd: 'Add external camera',
+    externalCameraRemove: 'Remove the external camera from {{name}}',
+    printerPickerSearch: 'Search printers',
+    printerPickerNoMatch: 'No printer matches.',
+    cameraShowSettings: 'Camera settings',
     test: 'Test',
     test: 'Test',
     connected: 'Connected',
     connected: 'Connected',
     disconnected: 'Disconnected',
     disconnected: 'Disconnected',

+ 16 - 0
frontend/src/i18n/locales/es.ts

@@ -2826,6 +2826,22 @@ export default {
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlHelp: 'URL de fotograma único usada para las miniaturas de notificaciones, las fotos de finalización, los fotogramas de time-lapse por capa y la detección de cama. El time-lapse y la detección de cama requieren cada uno su propio interruptor por impresora; esta URL es solo la fuente de imagen de la que se nutren cuando están activos. Déjelo en blanco para capturar desde la transmisión en directo de arriba. Útil para go2rtc (/api/frame.jpeg) y cámaras IP con un punto de conexión de captura dedicado.',
     cameraSnapshotUrlHelp: 'URL de fotograma único usada para las miniaturas de notificaciones, las fotos de finalización, los fotogramas de time-lapse por capa y la detección de cama. El time-lapse y la detección de cama requieren cada uno su propio interruptor por impresora; esta URL es solo la fuente de imagen de la que se nutren cuando están activos. Déjelo en blanco para capturar desde la transmisión en directo de arriba. Útil para go2rtc (/api/frame.jpeg) y cámaras IP con un punto de conexión de captura dedicado.',
     cameraRotation: 'Rotación',
     cameraRotation: 'Rotación',
+    cameraLight: 'Encender la luz de la cámara de impresión para la cámara',
+    cameraLightHint: 'Enciende la luz mientras alguien mira la cámara y para las capturas automáticas, como las fotos de las notificaciones, las fotos finales y las imágenes para Home Assistant. Después la vuelve a apagar, pero solo si la encendió Bambuddy.',
+    cameraLightDelay: 'Espera antes de la captura',
+    cameraLightDelayHint: 'Solo si la luz estaba apagada. Da tiempo a que se encienda la luz y cualquier luz sincronizada con ella.',
+    cameraLightMode_off: 'Desactivado',
+    cameraLightMode_all: 'Todas las impresoras',
+    cameraLightMode_selected: 'Impresoras seleccionadas',
+    cameraLightDelayUnit: 'segundos',
+    cameraLightNoneSelected: 'Aún no hay ninguna impresora elegida.',
+    cameraLightAddPrinter: 'Añadir impresora',
+    cameraLightRemovePrinter: 'Quitar {{name}}',
+    externalCameraAdd: 'Añadir cámara externa',
+    externalCameraRemove: 'Quitar la cámara externa de {{name}}',
+    printerPickerSearch: 'Buscar impresoras',
+    printerPickerNoMatch: 'Ninguna impresora coincide.',
+    cameraShowSettings: 'Ajustes de cámara',
     test: 'Probar',
     test: 'Probar',
     connected: 'Conectada',
     connected: 'Conectada',
     disconnected: 'Desconectada',
     disconnected: 'Desconectada',

+ 16 - 0
frontend/src/i18n/locales/fr.ts

@@ -2776,6 +2776,22 @@ export default {
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlHelp: 'URL d\'image unique utilisée pour les miniatures de notification, photos de fin, frames de timelapse par couche et détection de plaque. Le timelapse et la détection de plaque nécessitent chacun leur propre bascule par imprimante — cette URL est juste la source d\'image qu\'ils utilisent lorsqu\'activés. Laisser vide pour capturer depuis le flux en direct ci-dessus. Utile pour go2rtc (/api/frame.jpeg) et caméras IP avec un point de terminaison de capture dédié.',
     cameraSnapshotUrlHelp: 'URL d\'image unique utilisée pour les miniatures de notification, photos de fin, frames de timelapse par couche et détection de plaque. Le timelapse et la détection de plaque nécessitent chacun leur propre bascule par imprimante — cette URL est juste la source d\'image qu\'ils utilisent lorsqu\'activés. Laisser vide pour capturer depuis le flux en direct ci-dessus. Utile pour go2rtc (/api/frame.jpeg) et caméras IP avec un point de terminaison de capture dédié.',
     cameraRotation: 'Rotation',
     cameraRotation: 'Rotation',
+    cameraLight: 'Allumer l\'éclairage de la chambre pour la caméra',
+    cameraLightHint: 'Allume l\'éclairage pendant que quelqu\'un regarde la caméra et pour les captures automatiques, comme les photos des notifications, les photos de fin et les images pour Home Assistant. L\'éteint ensuite, mais seulement si Bambuddy l\'a allumé.',
+    cameraLightDelay: 'Attente avant la capture',
+    cameraLightDelayHint: 'Uniquement si l\'éclairage était éteint. Laisse à l\'éclairage, et à toute lampe synchronisée avec lui, le temps de s\'allumer.',
+    cameraLightMode_off: 'Désactivé',
+    cameraLightMode_all: 'Toutes les imprimantes',
+    cameraLightMode_selected: 'Imprimantes choisies',
+    cameraLightDelayUnit: 'secondes',
+    cameraLightNoneSelected: 'Aucune imprimante choisie pour l\'instant.',
+    cameraLightAddPrinter: 'Ajouter une imprimante',
+    cameraLightRemovePrinter: 'Retirer {{name}}',
+    externalCameraAdd: 'Ajouter une caméra externe',
+    externalCameraRemove: 'Retirer la caméra externe de {{name}}',
+    printerPickerSearch: 'Rechercher des imprimantes',
+    printerPickerNoMatch: 'Aucune imprimante ne correspond.',
+    cameraShowSettings: 'Réglages de la caméra',
     test: 'Tester',
     test: 'Tester',
     connected: 'Connecté',
     connected: 'Connecté',
     disconnected: 'Déconnecté',
     disconnected: 'Déconnecté',

+ 16 - 0
frontend/src/i18n/locales/it.ts

@@ -2775,6 +2775,22 @@ export default {
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlHelp: 'URL singolo frame usato per miniature di notifica, foto di fine, frame del layer timelapse e rilevamento del piano. Timelapse e rilevamento del piano richiedono ciascuno il proprio toggle per stampante — questo URL è solo la sorgente immagine che usano quando attivi. Lascia vuoto per acquisire dallo stream live sopra. Utile per go2rtc (/api/frame.jpeg) e telecamere IP con endpoint snapshot dedicato.',
     cameraSnapshotUrlHelp: 'URL singolo frame usato per miniature di notifica, foto di fine, frame del layer timelapse e rilevamento del piano. Timelapse e rilevamento del piano richiedono ciascuno il proprio toggle per stampante — questo URL è solo la sorgente immagine che usano quando attivi. Lascia vuoto per acquisire dallo stream live sopra. Utile per go2rtc (/api/frame.jpeg) e telecamere IP con endpoint snapshot dedicato.',
     cameraRotation: 'Rotazione',
     cameraRotation: 'Rotazione',
+    cameraLight: 'Accendi la luce della camera per la telecamera',
+    cameraLightHint: 'Accende la luce mentre qualcuno guarda la telecamera e per gli scatti automatici, come le foto delle notifiche, le foto di fine stampa e le immagini per Home Assistant. Poi la spegne di nuovo, ma solo se l\'ha accesa Bambuddy.',
+    cameraLightDelay: 'Attesa prima dello scatto',
+    cameraLightDelayHint: 'Solo se la luce era spenta. Dà alla luce, e a ogni luce sincronizzata con essa, il tempo di accendersi.',
+    cameraLightMode_off: 'Disattivata',
+    cameraLightMode_all: 'Tutte le stampanti',
+    cameraLightMode_selected: 'Stampanti scelte',
+    cameraLightDelayUnit: 'secondi',
+    cameraLightNoneSelected: 'Nessuna stampante scelta.',
+    cameraLightAddPrinter: 'Aggiungi stampante',
+    cameraLightRemovePrinter: 'Rimuovi {{name}}',
+    externalCameraAdd: 'Aggiungi telecamera esterna',
+    externalCameraRemove: 'Rimuovi la telecamera esterna da {{name}}',
+    printerPickerSearch: 'Cerca stampanti',
+    printerPickerNoMatch: 'Nessuna stampante corrisponde.',
+    cameraShowSettings: 'Impostazioni telecamera',
     test: 'Test',
     test: 'Test',
     connected: 'Connesso',
     connected: 'Connesso',
     disconnected: 'Disconnesso',
     disconnected: 'Disconnesso',

+ 16 - 0
frontend/src/i18n/locales/ja.ts

@@ -2823,6 +2823,22 @@ export default {
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlHelp: '通知サムネイル、終了写真、レイヤータイムラプスフレーム、プレート検出に使用される単一フレームURL。タイムラプスとプレート検出はそれぞれプリンターごとのトグルが必要 — このURLはアクティブ時の画像ソースに過ぎません。上記のライブストリームから取得する場合は空のままにします。go2rtc(/api/frame.jpeg)や専用スナップショットエンドポイントを持つIPカメラに便利です。',
     cameraSnapshotUrlHelp: '通知サムネイル、終了写真、レイヤータイムラプスフレーム、プレート検出に使用される単一フレームURL。タイムラプスとプレート検出はそれぞれプリンターごとのトグルが必要 — このURLはアクティブ時の画像ソースに過ぎません。上記のライブストリームから取得する場合は空のままにします。go2rtc(/api/frame.jpeg)や専用スナップショットエンドポイントを持つIPカメラに便利です。',
     cameraRotation: '回転',
     cameraRotation: '回転',
+    cameraLight: 'カメラ使用時にチャンバーライトを点灯',
+    cameraLightHint: '誰かがカメラを見ている間と、通知の写真・完了写真・Home Assistant の画像などの自動撮影時にライトを点灯します。その後、Bambuddy が点灯した場合に限り消灯します。',
+    cameraLightDelay: '撮影前の待機時間',
+    cameraLightDelayHint: 'ライトが消えていた場合のみ。ライトと、それに連動するライトが点灯するまでの時間です。',
+    cameraLightMode_off: 'オフ',
+    cameraLightMode_all: 'すべてのプリンター',
+    cameraLightMode_selected: '選択したプリンター',
+    cameraLightDelayUnit: '秒',
+    cameraLightNoneSelected: 'まだプリンターが選択されていません。',
+    cameraLightAddPrinter: 'プリンターを追加',
+    cameraLightRemovePrinter: '{{name}} を削除',
+    externalCameraAdd: '外部カメラを追加',
+    externalCameraRemove: '{{name}} から外部カメラを削除',
+    printerPickerSearch: 'プリンターを検索',
+    printerPickerNoMatch: '一致するプリンターはありません。',
+    cameraShowSettings: 'カメラ設定',
     test: 'テスト',
     test: 'テスト',
     connected: '接続済み',
     connected: '接続済み',
     disconnected: '未接続',
     disconnected: '未接続',

+ 16 - 0
frontend/src/i18n/locales/ko.ts

@@ -2686,6 +2686,22 @@ export default {
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlHelp: '알림 썸네일, 완료 사진, 타임랩스, 플레이트 감지에 사용되는 단일 프레임 URL.',
     cameraSnapshotUrlHelp: '알림 썸네일, 완료 사진, 타임랩스, 플레이트 감지에 사용되는 단일 프레임 URL.',
     cameraRotation: '회전',
     cameraRotation: '회전',
+    cameraLight: '카메라 사용 시 챔버 조명 켜기',
+    cameraLightHint: '누군가 카메라를 보는 동안과 알림 사진, 완료 사진, Home Assistant 이미지 같은 자동 촬영 시 조명을 켭니다. 이후 Bambuddy가 켠 경우에만 다시 끕니다.',
+    cameraLightDelay: '촬영 전 대기 시간',
+    cameraLightDelayHint: '조명이 꺼져 있었을 때만 적용됩니다. 조명과 연동된 조명이 켜질 시간을 줍니다.',
+    cameraLightMode_off: '끄기',
+    cameraLightMode_all: '모든 프린터',
+    cameraLightMode_selected: '선택한 프린터',
+    cameraLightDelayUnit: '초',
+    cameraLightNoneSelected: '아직 선택한 프린터가 없습니다.',
+    cameraLightAddPrinter: '프린터 추가',
+    cameraLightRemovePrinter: '{{name}} 제거',
+    externalCameraAdd: '외부 카메라 추가',
+    externalCameraRemove: '{{name}}에서 외부 카메라 제거',
+    printerPickerSearch: '프린터 검색',
+    printerPickerNoMatch: '일치하는 프린터가 없습니다.',
+    cameraShowSettings: '카메라 설정',
     test: '테스트',
     test: '테스트',
     connected: '연결됨',
     connected: '연결됨',
     disconnected: '연결 끊김',
     disconnected: '연결 끊김',

+ 16 - 0
frontend/src/i18n/locales/nl.ts

@@ -2844,6 +2844,22 @@ export default {
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlHelp: 'URL voor één frame, gebruikt voor miniaturen in meldingen, eindfoto\'s, frames van laagtimelapses en plaatdetectie. Timelapse en plaatdetectie vereisen elk hun eigen schakelaar per printer — deze URL is alleen de afbeeldingsbron die ze gebruiken wanneer actief. Laat leeg om beelden uit de bovenstaande livestream vast te leggen. Handig voor go2rtc (/api/frame.jpeg) en IP-camera\'s met een speciaal snapshot-eindpunt.',
     cameraSnapshotUrlHelp: 'URL voor één frame, gebruikt voor miniaturen in meldingen, eindfoto\'s, frames van laagtimelapses en plaatdetectie. Timelapse en plaatdetectie vereisen elk hun eigen schakelaar per printer — deze URL is alleen de afbeeldingsbron die ze gebruiken wanneer actief. Laat leeg om beelden uit de bovenstaande livestream vast te leggen. Handig voor go2rtc (/api/frame.jpeg) en IP-camera\'s met een speciaal snapshot-eindpunt.',
     cameraRotation: 'Rotatie',
     cameraRotation: 'Rotatie',
+    cameraLight: 'Kamerverlichting aanzetten voor de camera',
+    cameraLightHint: 'Zet het licht aan zolang iemand naar de camera kijkt en voor automatische opnamen, zoals foto\'s in meldingen, eindfoto\'s en beelden voor Home Assistant. Zet het daarna weer uit, maar alleen als Bambuddy het aanzette.',
+    cameraLightDelay: 'Wachttijd voor de opname',
+    cameraLightDelayHint: 'Alleen als het licht uit was. Geeft het licht, en lampen die ermee gekoppeld zijn, tijd om aan te gaan.',
+    cameraLightMode_off: 'Uit',
+    cameraLightMode_all: 'Alle printers',
+    cameraLightMode_selected: 'Gekozen printers',
+    cameraLightDelayUnit: 'seconden',
+    cameraLightNoneSelected: 'Nog geen printer gekozen.',
+    cameraLightAddPrinter: 'Printer toevoegen',
+    cameraLightRemovePrinter: '{{name}} verwijderen',
+    externalCameraAdd: 'Externe camera toevoegen',
+    externalCameraRemove: 'Externe camera van {{name}} verwijderen',
+    printerPickerSearch: 'Printers zoeken',
+    printerPickerNoMatch: 'Geen printer gevonden.',
+    cameraShowSettings: 'Camera-instellingen',
     test: 'Testen',
     test: 'Testen',
     connected: 'Verbonden',
     connected: 'Verbonden',
     disconnected: 'Niet verbonden',
     disconnected: 'Niet verbonden',

+ 16 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -2775,6 +2775,22 @@ export default {
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlHelp: 'URL de frame único usado para miniaturas de notificação, fotos de finalização, frames do timelapse por camada e detecção de mesa. Timelapse e detecção de mesa requerem cada um seu próprio toggle por impressora — esta URL é apenas a fonte de imagem que eles usam quando ativos. Deixe em branco para capturar do stream ao vivo acima. Útil para go2rtc (/api/frame.jpeg) e câmeras IP com endpoint de captura dedicado.',
     cameraSnapshotUrlHelp: 'URL de frame único usado para miniaturas de notificação, fotos de finalização, frames do timelapse por camada e detecção de mesa. Timelapse e detecção de mesa requerem cada um seu próprio toggle por impressora — esta URL é apenas a fonte de imagem que eles usam quando ativos. Deixe em branco para capturar do stream ao vivo acima. Útil para go2rtc (/api/frame.jpeg) e câmeras IP com endpoint de captura dedicado.',
     cameraRotation: 'Rotação',
     cameraRotation: 'Rotação',
+    cameraLight: 'Acender a luz da câmara para a câmera',
+    cameraLightHint: 'Acende a luz enquanto alguém assiste à câmera e para as capturas automáticas, como fotos de notificações, fotos de conclusão e imagens do Home Assistant. Depois apaga de novo, mas só se o Bambuddy a acendeu.',
+    cameraLightDelay: 'Espera antes da captura',
+    cameraLightDelayHint: 'Só quando a luz estava apagada. Dá tempo para a luz, e qualquer luz sincronizada com ela, acender.',
+    cameraLightMode_off: 'Desligado',
+    cameraLightMode_all: 'Todas as impressoras',
+    cameraLightMode_selected: 'Impressoras escolhidas',
+    cameraLightDelayUnit: 'segundos',
+    cameraLightNoneSelected: 'Nenhuma impressora escolhida ainda.',
+    cameraLightAddPrinter: 'Adicionar impressora',
+    cameraLightRemovePrinter: 'Remover {{name}}',
+    externalCameraAdd: 'Adicionar câmera externa',
+    externalCameraRemove: 'Remover a câmera externa de {{name}}',
+    printerPickerSearch: 'Pesquisar impressoras',
+    printerPickerNoMatch: 'Nenhuma impressora corresponde.',
+    cameraShowSettings: 'Configurações da câmera',
     test: 'Testar',
     test: 'Testar',
     connected: 'Conectado',
     connected: 'Conectado',
     disconnected: 'Desconectado',
     disconnected: 'Desconectado',

+ 16 - 0
frontend/src/i18n/locales/ru.ts

@@ -2690,6 +2690,22 @@ export default {
     cameraSnapshotUrlPlaceholder: "http://192.168.1.61:1984/api/frame.jpeg?src=printer",
     cameraSnapshotUrlPlaceholder: "http://192.168.1.61:1984/api/frame.jpeg?src=printer",
     cameraSnapshotUrlHelp: "URL одиночного кадра для миниатюр уведомлений, снимков после печати, кадров послойного таймлапса и распознавания пластины. Для таймлапса и распознавания пластины должны быть отдельно включены соответствующие настройки принтера; этот URL задаёт лишь источник изображения. Оставьте пустым, чтобы получать кадры из указанного выше видеопотока. Полезно для go2rtc (/api/frame.jpeg) и IP-камер с отдельным endpoint снимка.",
     cameraSnapshotUrlHelp: "URL одиночного кадра для миниатюр уведомлений, снимков после печати, кадров послойного таймлапса и распознавания пластины. Для таймлапса и распознавания пластины должны быть отдельно включены соответствующие настройки принтера; этот URL задаёт лишь источник изображения. Оставьте пустым, чтобы получать кадры из указанного выше видеопотока. Полезно для go2rtc (/api/frame.jpeg) и IP-камер с отдельным endpoint снимка.",
     cameraRotation: "Поворот",
     cameraRotation: "Поворот",
+    cameraLight: "Включать освещение камеры принтера для камеры",
+    cameraLightHint: "Включает освещение, пока кто-то смотрит камеру, и для автоматических снимков: фото в уведомлениях, фото по завершении и изображений для Home Assistant. Потом выключает его, но только если его включил Bambuddy.",
+    cameraLightDelay: "Задержка перед снимком",
+    cameraLightDelayHint: "Только если освещение было выключено. Даёт время включиться освещению и связанным с ним лампам.",
+    cameraLightMode_off: "Выкл.",
+    cameraLightMode_all: "Все принтеры",
+    cameraLightMode_selected: "Выбранные принтеры",
+    cameraLightDelayUnit: "секунд",
+    cameraLightNoneSelected: "Принтеры ещё не выбраны.",
+    cameraLightAddPrinter: "Добавить принтер",
+    cameraLightRemovePrinter: "Убрать {{name}}",
+    externalCameraAdd: "Добавить внешнюю камеру",
+    externalCameraRemove: "Убрать внешнюю камеру у {{name}}",
+    printerPickerSearch: "Поиск принтеров",
+    printerPickerNoMatch: "Подходящих принтеров нет.",
+    cameraShowSettings: "Настройки камеры",
     test: "Проверить",
     test: "Проверить",
     connected: "Подключено",
     connected: "Подключено",
     disconnected: "Отключено",
     disconnected: "Отключено",

+ 16 - 0
frontend/src/i18n/locales/sv.ts

@@ -2843,6 +2843,22 @@ export default {
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlHelp: 'Enkelbild-URL som används för notisminibilder, färdigbilder, layer-timelapse-bilder och plattdetektering. Timelapse och plattdetektering kräver varsin per-skrivare-växel — denna URL är bara bildkällan de använder när aktiva. Lämna tomt för att fånga från live-strömmen ovan. Användbart för go2rtc (/api/frame.jpeg) och IP-kameror med en dedikerad ögonblicksbild-ändpunkt.',
     cameraSnapshotUrlHelp: 'Enkelbild-URL som används för notisminibilder, färdigbilder, layer-timelapse-bilder och plattdetektering. Timelapse och plattdetektering kräver varsin per-skrivare-växel — denna URL är bara bildkällan de använder när aktiva. Lämna tomt för att fånga från live-strömmen ovan. Användbart för go2rtc (/api/frame.jpeg) och IP-kameror med en dedikerad ögonblicksbild-ändpunkt.',
     cameraRotation: 'Rotation',
     cameraRotation: 'Rotation',
+    cameraLight: 'Tänd kammarljuset för kameran',
+    cameraLightHint: 'Tänder ljuset medan någon tittar på kameran och för automatiska bilder, till exempel foton i aviseringar, slutfoton och bilder till Home Assistant. Släcker det sedan igen, men bara om Bambuddy tände det.',
+    cameraLightDelay: 'Vänta före bilden',
+    cameraLightDelayHint: 'Bara om ljuset var släckt. Ger ljuset, och lampor som är synkade med det, tid att tändas.',
+    cameraLightMode_off: 'Av',
+    cameraLightMode_all: 'Alla skrivare',
+    cameraLightMode_selected: 'Valda skrivare',
+    cameraLightDelayUnit: 'sekunder',
+    cameraLightNoneSelected: 'Ingen skrivare vald än.',
+    cameraLightAddPrinter: 'Lägg till skrivare',
+    cameraLightRemovePrinter: 'Ta bort {{name}}',
+    externalCameraAdd: 'Lägg till extern kamera',
+    externalCameraRemove: 'Ta bort den externa kameran från {{name}}',
+    printerPickerSearch: 'Sök skrivare',
+    printerPickerNoMatch: 'Ingen skrivare matchar.',
+    cameraShowSettings: 'Kamerainställningar',
     test: 'Test',
     test: 'Test',
     connected: 'Ansluten',
     connected: 'Ansluten',
     disconnected: 'Frånkopplad',
     disconnected: 'Frånkopplad',

+ 16 - 0
frontend/src/i18n/locales/tr.ts

@@ -2826,6 +2826,22 @@ export default {
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlHelp: 'Bildirim küçük resimleri, bitiş fotoğrafları, katman zaman atlamalı kareler ve plaka algılama için kullanılan tek kare URL\'si. Zaman atlamalı video ve plaka algılama, her birinin kendi yazıcı başına anahtarı gerektirir — bu URL yalnızca aktif olduklarında çektikleri görüntü kaynağıdır. Yukarıdaki canlı akıştan yakalamak için boş bırakın. go2rtc (/api/frame.jpeg) ve özel anlık görüntü uç noktasına sahip IP kameraları için kullanışlıdır.',
     cameraSnapshotUrlHelp: 'Bildirim küçük resimleri, bitiş fotoğrafları, katman zaman atlamalı kareler ve plaka algılama için kullanılan tek kare URL\'si. Zaman atlamalı video ve plaka algılama, her birinin kendi yazıcı başına anahtarı gerektirir — bu URL yalnızca aktif olduklarında çektikleri görüntü kaynağıdır. Yukarıdaki canlı akıştan yakalamak için boş bırakın. go2rtc (/api/frame.jpeg) ve özel anlık görüntü uç noktasına sahip IP kameraları için kullanışlıdır.',
     cameraRotation: 'Döndürme',
     cameraRotation: 'Döndürme',
+    cameraLight: 'Kamera için hazne ışığını aç',
+    cameraLightHint: 'Biri kamerayı izlerken ve bildirim fotoğrafları, bitiş fotoğrafları ve Home Assistant görüntüleri gibi otomatik çekimlerde ışığı açar. Ardından ışığı yeniden kapatır, ancak yalnızca Bambuddy açtıysa.',
+    cameraLightDelay: 'Çekimden önce bekleme',
+    cameraLightDelayHint: 'Yalnızca ışık kapalıysa. Işığa ve ona bağlı ışıklara yanma süresi tanır.',
+    cameraLightMode_off: 'Kapalı',
+    cameraLightMode_all: 'Tüm yazıcılar',
+    cameraLightMode_selected: 'Seçili yazıcılar',
+    cameraLightDelayUnit: 'saniye',
+    cameraLightNoneSelected: 'Henüz yazıcı seçilmedi.',
+    cameraLightAddPrinter: 'Yazıcı ekle',
+    cameraLightRemovePrinter: '{{name}} kaldır',
+    externalCameraAdd: 'Harici kamera ekle',
+    externalCameraRemove: '{{name}} yazıcısından harici kamerayı kaldır',
+    printerPickerSearch: 'Yazıcı ara',
+    printerPickerNoMatch: 'Eşleşen yazıcı yok.',
+    cameraShowSettings: 'Kamera ayarları',
     test: 'Test',
     test: 'Test',
     connected: 'Bağlı',
     connected: 'Bağlı',
     disconnected: 'Bağlı değil',
     disconnected: 'Bağlı değil',

+ 16 - 0
frontend/src/i18n/locales/uk.ts

@@ -2841,6 +2841,22 @@ export default {
     cameraSnapshotUrlPlaceholder: "http://192.168.1.61:1984/api/frame.jpeg?src=printer",
     cameraSnapshotUrlPlaceholder: "http://192.168.1.61:1984/api/frame.jpeg?src=printer",
     cameraSnapshotUrlHelp: "Одиночний кадр URL використовується для ескізів сповіщень, фінішних фотографій, шарових кадрів уповільненої зйомки та виявлення пластин. Уповільнена зйомка та виявлення пластин потребують окремого перемикача для кожного принтера — це URL є лише джерелом зображення, з якого вони беруть участь, коли активні. Залиште поле порожнім для запису з прямої трансляції вище. Корисно для камер go2rtc (/api/frame.jpeg) і IP із спеціальною кінцевою точкою знімка.",
     cameraSnapshotUrlHelp: "Одиночний кадр URL використовується для ескізів сповіщень, фінішних фотографій, шарових кадрів уповільненої зйомки та виявлення пластин. Уповільнена зйомка та виявлення пластин потребують окремого перемикача для кожного принтера — це URL є лише джерелом зображення, з якого вони беруть участь, коли активні. Залиште поле порожнім для запису з прямої трансляції вище. Корисно для камер go2rtc (/api/frame.jpeg) і IP із спеціальною кінцевою точкою знімка.",
     cameraRotation: "Обертання",
     cameraRotation: "Обертання",
+    cameraLight: "Вмикати освітлення камери принтера для камери",
+    cameraLightHint: "Вмикає освітлення, поки хтось дивиться камеру, і для автоматичних знімків: фото в сповіщеннях, фото після завершення та зображень для Home Assistant. Потім вимикає його, але лише якщо його ввімкнув Bambuddy.",
+    cameraLightDelay: "Затримка перед знімком",
+    cameraLightDelayHint: "Лише якщо освітлення було вимкнене. Дає час увімкнутися освітленню та пов'язаним з ним лампам.",
+    cameraLightMode_off: "Вимк.",
+    cameraLightMode_all: "Усі принтери",
+    cameraLightMode_selected: "Вибрані принтери",
+    cameraLightDelayUnit: "секунд",
+    cameraLightNoneSelected: "Принтери ще не вибрано.",
+    cameraLightAddPrinter: "Додати принтер",
+    cameraLightRemovePrinter: "Прибрати {{name}}",
+    externalCameraAdd: "Додати зовнішню камеру",
+    externalCameraRemove: "Прибрати зовнішню камеру з {{name}}",
+    printerPickerSearch: "Пошук принтерів",
+    printerPickerNoMatch: "Немає відповідних принтерів.",
+    cameraShowSettings: "Налаштування камери",
     test: "Тест",
     test: "Тест",
     connected: "Підключено",
     connected: "Підключено",
     disconnected: "Відключено",
     disconnected: "Відключено",

+ 16 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -2820,6 +2820,22 @@ export default {
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlHelp: '用于通知缩略图、完成照片、层延时摄影帧和打印板检测的单帧 URL。延时摄影和打印板检测各自需要自己的每打印机开关 — 此 URL 只是它们激活时拉取的图像源。留空以从上方实时流捕获。适用于 go2rtc(/api/frame.jpeg)和具有专用快照端点的 IP 摄像头。',
     cameraSnapshotUrlHelp: '用于通知缩略图、完成照片、层延时摄影帧和打印板检测的单帧 URL。延时摄影和打印板检测各自需要自己的每打印机开关 — 此 URL 只是它们激活时拉取的图像源。留空以从上方实时流捕获。适用于 go2rtc(/api/frame.jpeg)和具有专用快照端点的 IP 摄像头。',
     cameraRotation: '旋转',
     cameraRotation: '旋转',
+    cameraLight: '使用摄像头时打开腔室灯',
+    cameraLightHint: '有人观看摄像头时以及自动拍摄(如通知照片、完成照片和 Home Assistant 图片)时打开灯光。之后会再关闭,但仅限由 Bambuddy 打开的情况。',
+    cameraLightDelay: '拍摄前等待',
+    cameraLightDelayHint: '仅在灯原本关闭时生效。给灯光及与其联动的灯留出点亮时间。',
+    cameraLightMode_off: '关闭',
+    cameraLightMode_all: '所有打印机',
+    cameraLightMode_selected: '选定的打印机',
+    cameraLightDelayUnit: '秒',
+    cameraLightNoneSelected: '尚未选择打印机。',
+    cameraLightAddPrinter: '添加打印机',
+    cameraLightRemovePrinter: '移除 {{name}}',
+    externalCameraAdd: '添加外部摄像头',
+    externalCameraRemove: '移除 {{name}} 的外部摄像头',
+    printerPickerSearch: '搜索打印机',
+    printerPickerNoMatch: '没有匹配的打印机。',
+    cameraShowSettings: '摄像头设置',
     test: '测试',
     test: '测试',
     connected: '已连接',
     connected: '已连接',
     disconnected: '未连接',
     disconnected: '未连接',

+ 16 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -2820,6 +2820,22 @@ export default {
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlPlaceholder: 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
     cameraSnapshotUrlHelp: '用於通知縮圖、完成照片、層縮時攝影影格和列印板偵測的單一影格 URL。縮時攝影和列印板偵測各自需要自己的每印表機切換 — 此 URL 只是它們啟用時拉取的影像來源。留空以從上方即時串流擷取。適用於 go2rtc(/api/frame.jpeg)和具有專用快照端點的 IP 攝影機。',
     cameraSnapshotUrlHelp: '用於通知縮圖、完成照片、層縮時攝影影格和列印板偵測的單一影格 URL。縮時攝影和列印板偵測各自需要自己的每印表機切換 — 此 URL 只是它們啟用時拉取的影像來源。留空以從上方即時串流擷取。適用於 go2rtc(/api/frame.jpeg)和具有專用快照端點的 IP 攝影機。',
     cameraRotation: '旋轉',
     cameraRotation: '旋轉',
+    cameraLight: '使用攝影機時開啟腔室燈',
+    cameraLightHint: '有人觀看攝影機時以及自動拍攝(如通知照片、完成照片和 Home Assistant 圖片)時開啟燈光。之後會再關閉,但僅限由 Bambuddy 開啟的情況。',
+    cameraLightDelay: '拍攝前等待',
+    cameraLightDelayHint: '僅在燈原本關閉時生效。讓燈光及與其連動的燈有時間亮起。',
+    cameraLightMode_off: '關閉',
+    cameraLightMode_all: '所有印表機',
+    cameraLightMode_selected: '選定的印表機',
+    cameraLightDelayUnit: '秒',
+    cameraLightNoneSelected: '尚未選擇印表機。',
+    cameraLightAddPrinter: '新增印表機',
+    cameraLightRemovePrinter: '移除 {{name}}',
+    externalCameraAdd: '新增外部攝影機',
+    externalCameraRemove: '移除 {{name}} 的外部攝影機',
+    printerPickerSearch: '搜尋印表機',
+    printerPickerNoMatch: '沒有符合的印表機。',
+    cameraShowSettings: '攝影機設定',
     test: '測試',
     test: '測試',
     connected: '已連線',
     connected: '已連線',
     disconnected: '未連線',
     disconnected: '未連線',

+ 246 - 113
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Pencil, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog, QrCode, Heart, Briefcase, Workflow, UploadCloud, MonitorPlay } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Pencil, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog, QrCode, Heart, Briefcase, Workflow, UploadCloud, MonitorPlay, Info } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { api } from '../api/client';
 import { api } from '../api/client';
@@ -18,7 +18,7 @@ import {
 } from '../utils/locationSensorDefaults';
 } from '../utils/locationSensorDefaults';
 import { describeHASensorReading, iconForHASensor } from '../utils/haSensorDisplay';
 import { describeHASensorReading, iconForHASensor } from '../utils/haSensorDisplay';
 import { PreheatFilamentTargetsEditor } from '../components/PreheatFilamentTargetsEditor';
 import { PreheatFilamentTargetsEditor } from '../components/PreheatFilamentTargetsEditor';
-import type { APIKey, AppSettings, AppSettingsUpdate, PrinterHASensor, LocationHASensor, LocationHASensorReading, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse, CalibrationMode } from '../api/client';
+import type { Printer as PrinterInfo, APIKey, AppSettings, AppSettingsUpdate, PrinterHASensor, LocationHASensor, LocationHASensorReading, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse, CalibrationMode } from '../api/client';
 import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
 import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
 import { SlicerPipelinesPanel } from '../components/SlicerPipelinesPanel';
 import { SlicerPipelinesPanel } from '../components/SlicerPipelinesPanel';
 import { CameraTokensSection } from './CameraTokensPage';
 import { CameraTokensSection } from './CameraTokensPage';
@@ -65,6 +65,7 @@ import { registerSettingsSearch, getSettingsSearchEntries } from '../lib/setting
 import type { UsersSubTab } from '../lib/settingsSearch';
 import type { UsersSubTab } from '../lib/settingsSearch';
 import { availableEngines, hasEngineChoice, resolveEngine, type SliceEngineId } from '../lib/sliceEngines';
 import { availableEngines, hasEngineChoice, resolveEngine, type SliceEngineId } from '../lib/sliceEngines';
 import { NumberInput } from '../components/NumberInput';
 import { NumberInput } from '../components/NumberInput';
+import { PrinterSearchPicker } from '../components/PrinterSearchPicker';
 
 
 const validTabs = ['general', 'plugs', 'sensors', 'notifications', 'queue', 'filament', 'camera', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const;
 const validTabs = ['general', 'plugs', 'sensors', 'notifications', 'queue', 'filament', 'camera', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const;
 type TabType = typeof validTabs[number];
 type TabType = typeof validTabs[number];
@@ -76,7 +77,7 @@ interface SettingsTabEntry { tab: TabType; icon: LucideIcon; label: string; extr
 registerSettingsSearch({ labelKey: 'settings.general', tab: 'general', keywords: 'language date time format printer model printers cards', anchor: 'card-general' });
 registerSettingsSearch({ labelKey: 'settings.general', tab: 'general', keywords: 'language date time format printer model printers cards', anchor: 'card-general' });
 registerSettingsSearch({ labelKey: 'settings.appearance', tab: 'general', keywords: 'theme dark light mode colors', anchor: 'card-appearance' });
 registerSettingsSearch({ labelKey: 'settings.appearance', tab: 'general', keywords: 'theme dark light mode colors', anchor: 'card-appearance' });
 registerSettingsSearch({ labelKey: 'settings.archiveSettings', tab: 'general', keywords: 'archive auto save thumbnails captures', anchor: 'card-archive' });
 registerSettingsSearch({ labelKey: 'settings.archiveSettings', tab: 'general', keywords: 'archive auto save thumbnails captures', anchor: 'card-archive' });
-registerSettingsSearch({ labelKey: 'settings.camera', tab: 'camera', keywords: 'camera external video stream rtsp mjpeg usb snapshot rotation', anchor: 'card-camera' });
+registerSettingsSearch({ labelKey: 'settings.camera', tab: 'camera', keywords: 'camera external video stream rtsp mjpeg usb snapshot rotation chamber light led lamp', anchor: 'card-camera' });
 registerSettingsSearch({ labelKey: 'settings.costTracking', tab: 'general', keywords: 'currency filament cost energy kwh price', anchor: 'card-cost' });
 registerSettingsSearch({ labelKey: 'settings.costTracking', tab: 'general', keywords: 'currency filament cost energy kwh price', anchor: 'card-cost' });
 registerSettingsSearch({ labelKey: 'settings.fileManager', tab: 'general', keywords: 'file manager archive mode disk warning storage', anchor: 'card-filemanager' });
 registerSettingsSearch({ labelKey: 'settings.fileManager', tab: 'general', keywords: 'file manager archive mode disk warning storage', anchor: 'card-filemanager' });
 registerSettingsSearch({ labelKey: 'settings.updates', tab: 'general', keywords: 'updates version firmware beta check', anchor: 'card-updates' });
 registerSettingsSearch({ labelKey: 'settings.updates', tab: 'general', keywords: 'updates version firmware beta check', anchor: 'card-updates' });
@@ -200,6 +201,23 @@ function describeLocationSensorValue(
   return describeHASensorReading(reading, t, { decimals: 2 });
   return describeHASensorReading(reading, t, { decimals: 2 });
 }
 }
 
 
+// Chamber light for the camera (#1655), in the order the choice is shown.
+const CAMERA_LIGHT_MODES = ['off', 'all', 'selected'] as const;
+
+/** Closed-row hint for an external camera: its type and where it points. */
+function externalCameraSummary(printer: PrinterInfo): string {
+  const type = (printer.external_camera_type || 'mjpeg').toUpperCase();
+  const url = printer.external_camera_url || '';
+  if (!url) return type;
+  let where = url;
+  try {
+    where = new URL(url).host || url;
+  } catch {
+    // A USB device path or an unfinished URL: show it as typed.
+  }
+  return `${type} · ${where}`;
+}
+
 export function SettingsPage() {
 export function SettingsPage() {
   const [cameraTokenRevision, setCameraTokenRevision] = useState(0);
   const [cameraTokenRevision, setCameraTokenRevision] = useState(0);
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
@@ -1134,7 +1152,7 @@ export function SettingsPage() {
   });
   });
 
 
   const updatePrinterMutation = useMutation({
   const updatePrinterMutation = useMutation({
-    mutationFn: ({ id, data }: { id: number; data: Partial<{ external_camera_url: string | null; external_camera_type: string | null; external_camera_enabled: boolean; external_camera_snapshot_url: string | null; camera_rotation: number }> }) =>
+    mutationFn: ({ id, data }: { id: number; data: Partial<{ external_camera_url: string | null; external_camera_type: string | null; external_camera_enabled: boolean; external_camera_snapshot_url: string | null; camera_rotation: number; camera_light_auto: boolean }> }) =>
       api.updatePrinter(id, data),
       api.updatePrinter(id, data),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['printers'] });
       queryClient.invalidateQueries({ queryKey: ['printers'] });
@@ -1215,6 +1233,8 @@ export function SettingsPage() {
       baseline.ha_token !== localSettings.ha_token ||
       baseline.ha_token !== localSettings.ha_token ||
       (baseline.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') ||
       (baseline.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') ||
       Number(baseline.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
       Number(baseline.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
+      (baseline.camera_light_mode ?? 'off') !== (localSettings.camera_light_mode ?? 'off') ||
+      Number(baseline.camera_light_delay ?? 2) !== Number(localSettings.camera_light_delay ?? 2) ||
       (baseline.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||
       (baseline.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||
       resolveEngine(baseline.slice_engine) !== resolveEngine(localSettings.slice_engine) ||
       resolveEngine(baseline.slice_engine) !== resolveEngine(localSettings.slice_engine) ||
       (baseline.open_in_slicer ?? null) !== (localSettings.open_in_slicer ?? null) ||
       (baseline.open_in_slicer ?? null) !== (localSettings.open_in_slicer ?? null) ||
@@ -1333,6 +1353,8 @@ export function SettingsPage() {
         ha_token: localSettings.ha_token,
         ha_token: localSettings.ha_token,
         library_archive_mode: localSettings.library_archive_mode,
         library_archive_mode: localSettings.library_archive_mode,
         library_disk_warning_gb: localSettings.library_disk_warning_gb,
         library_disk_warning_gb: localSettings.library_disk_warning_gb,
+        camera_light_mode: localSettings.camera_light_mode,
+        camera_light_delay: localSettings.camera_light_delay,
         preferred_slicer: localSettings.preferred_slicer,
         preferred_slicer: localSettings.preferred_slicer,
         slice_engine: localSettings.slice_engine,
         slice_engine: localSettings.slice_engine,
         open_in_slicer: localSettings.open_in_slicer,
         open_in_slicer: localSettings.open_in_slicer,
@@ -1433,6 +1455,7 @@ export function SettingsPage() {
   const initializedPrinterUrlsRef = useRef<Set<number>>(new Set());
   const initializedPrinterUrlsRef = useRef<Set<number>>(new Set());
   const [localSnapshotUrls, setLocalSnapshotUrls] = useState<Record<number, string>>({});
   const [localSnapshotUrls, setLocalSnapshotUrls] = useState<Record<number, string>>({});
   const snapshotUrlSaveTimeoutRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
   const snapshotUrlSaveTimeoutRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
+  const [openCameraPrinterId, setOpenCameraPrinterId] = useState<number | null>(null);
   const initializedPrinterSnapshotUrlsRef = useRef<Set<number>>(new Set());
   const initializedPrinterSnapshotUrlsRef = useRef<Set<number>>(new Set());
 
 
   // Initialize local camera URLs from printer data
   // Initialize local camera URLs from printer data
@@ -1492,11 +1515,12 @@ export function SettingsPage() {
     }, 800);
     }, 800);
   };
   };
 
 
-  const handleUpdatePrinterCamera = (printerId: number, updates: { type?: string; enabled?: boolean; rotation?: number }) => {
-    const data: Partial<{ external_camera_type: string | null; external_camera_enabled: boolean; camera_rotation: number }> = {};
+  const handleUpdatePrinterCamera = (printerId: number, updates: { type?: string; enabled?: boolean; rotation?: number; lightAuto?: boolean }) => {
+    const data: Partial<{ external_camera_type: string | null; external_camera_enabled: boolean; camera_rotation: number; camera_light_auto: boolean }> = {};
     if (updates.type !== undefined) data.external_camera_type = updates.type || null;
     if (updates.type !== undefined) data.external_camera_type = updates.type || null;
     if (updates.enabled !== undefined) data.external_camera_enabled = updates.enabled;
     if (updates.enabled !== undefined) data.external_camera_enabled = updates.enabled;
     if (updates.rotation !== undefined) data.camera_rotation = updates.rotation;
     if (updates.rotation !== undefined) data.camera_rotation = updates.rotation;
+    if (updates.lightAuto !== undefined) data.camera_light_auto = updates.lightAuto;
     updatePrinterMutation.mutate({ id: printerId, data });
     updatePrinterMutation.mutate({ id: printerId, data });
   };
   };
 
 
@@ -1556,6 +1580,10 @@ export function SettingsPage() {
     ? `cd ${/\s/.test(composeDir) ? `"${composeDir}"` : composeDir} && docker compose pull && docker compose up -d`
     ? `cd ${/\s/.test(composeDir) ? `"${composeDir}"` : composeDir} && docker compose pull && docker compose up -d`
     : 'docker compose pull && docker compose up -d';
     : 'docker compose pull && docker compose up -d';
 
 
+  const cameraLightMode = localSettings.camera_light_mode ?? 'off';
+  const lightPrinters = (printers ?? []).filter(p => p.camera_light_auto);
+  const externalCameraPrinters = (printers ?? []).filter(p => p.external_camera_enabled);
+
   const countBadge = (count: number) => count > 0 && (
   const countBadge = (count: number) => count > 0 && (
     <span className="text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full shrink-0">{count}</span>
     <span className="text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full shrink-0">{count}</span>
   );
   );
@@ -4207,129 +4235,234 @@ export function SettingsPage() {
                     per stream instead of once for the whole install. The stored
                     per stream instead of once for the whole install. The stored
                     setting stays as the default a fresh browser starts from. */}
                     setting stays as the default a fresh browser starts from. */}
 
 
-                {/* External Cameras Section */}
-                <div>
-                  <h3 className="text-sm font-medium text-white mb-2">{t('settings.externalCameras')}</h3>
-                  <p className="text-xs text-bambu-gray mb-3">
-                    {t('settings.externalCamerasDescription')}
-                  </p>
+                {/* Chamber light for the camera (#1655): one setting for the whole
+                    install, with a printer list only for "Selected printers".
+                    Nothing here grows with the number of printers. */}
+                <section className="space-y-2">
+                  <div>
+                    <h3 className="text-sm font-medium text-white">{t('settings.cameraLight')}</h3>
+                    <p className="text-xs text-bambu-gray">{t('settings.cameraLightHint')}</p>
+                  </div>
+                  <div role="radiogroup" aria-label={t('settings.cameraLight')} className="inline-flex rounded-lg border border-bambu-dark-tertiary overflow-hidden">
+                    {CAMERA_LIGHT_MODES.map(mode => (
+                      <label key={mode} className="cursor-pointer">
+                        <input
+                          type="radio"
+                          name="camera-light-mode"
+                          value={mode}
+                          checked={cameraLightMode === mode}
+                          onChange={() => updateSetting('camera_light_mode', mode)}
+                          className="sr-only peer"
+                        />
+                        <span className="block px-3 py-1.5 text-xs text-bambu-gray peer-checked:bg-bambu-green peer-checked:text-white peer-focus-visible:ring-2 peer-focus-visible:ring-inset peer-focus-visible:ring-white hover:text-white">
+                          {t(`settings.cameraLightMode_${mode}`)}
+                        </span>
+                      </label>
+                    ))}
+                  </div>
+                  {cameraLightMode !== 'off' && (
+                    <div>
+                      <div className="flex items-center gap-2">
+                        <label htmlFor="camera-light-delay" className="text-xs text-bambu-gray">{t('settings.cameraLightDelay')}</label>
+                        <NumberInput
+                          id="camera-light-delay"
+                          min={0}
+                          max={5}
+                          step="0.5"
+                          integer={false}
+                          fallback={2}
+                          value={localSettings.camera_light_delay ?? 2}
+                          onChange={(v) => updateSetting('camera_light_delay', v)}
+                          className="w-16 px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs text-center focus:border-bambu-green focus:outline-none"
+                        />
+                        <span className="text-xs text-bambu-gray">{t('settings.cameraLightDelayUnit')}</span>
+                      </div>
+                      <p className="text-xs text-bambu-gray opacity-75 mt-1">{t('settings.cameraLightDelayHint')}</p>
+                    </div>
+                  )}
+                  {cameraLightMode === 'selected' && (
+                    <div className="flex flex-wrap items-center gap-1.5" data-testid="camera-light-printers">
+                      {lightPrinters.length === 0 && (
+                        <span className="text-xs text-bambu-gray italic">{t('settings.cameraLightNoneSelected')}</span>
+                      )}
+                      {lightPrinters.map(printer => (
+                        <span key={printer.id} className="inline-flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-full bg-bambu-dark-tertiary text-xs text-white">
+                          {printer.name}
+                          <button
+                            type="button"
+                            onClick={() => handleUpdatePrinterCamera(printer.id, { lightAuto: false })}
+                            aria-label={t('settings.cameraLightRemovePrinter', { name: printer.name })}
+                            className="p-0.5 rounded-full text-bambu-gray hover:text-white"
+                          >
+                            <X className="w-3 h-3" />
+                          </button>
+                        </span>
+                      ))}
+                      <PrinterSearchPicker
+                        printers={(printers ?? []).filter(p => !p.camera_light_auto)}
+                        onPick={(p) => handleUpdatePrinterCamera(p.id, { lightAuto: true })}
+                        label={t('settings.cameraLightAddPrinter')}
+                      />
+                    </div>
+                  )}
+                </section>
 
 
-                  {printers && printers.length > 0 ? (
-                    <div className="space-y-3">
-                      {printers.map(printer => (
-                        <div key={printer.id} className="p-3 bg-bambu-dark rounded-lg">
-                          <div className="flex items-center justify-between mb-2">
-                            <span className="text-white font-medium text-sm">{printer.name}</span>
-                            <label className="relative inline-flex items-center cursor-pointer">
-                              <input
-                                type="checkbox"
-                                checked={printer.external_camera_enabled}
-                                onChange={(e) => handleUpdatePrinterCamera(printer.id, { enabled: e.target.checked })}
-                                className="sr-only peer"
-                              />
-                              <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
-                            </label>
-                          </div>
+                {/* External cameras: only the printers that have one, so a farm
+                    sees a handful of rows, not every printer it owns. */}
+                <section className="space-y-2 pt-4 border-t border-bambu-dark-tertiary">
+                  <div>
+                    <h3 className="text-sm font-medium text-white">{t('settings.externalCameras')}</h3>
+                    <p className="text-xs text-bambu-gray">{t('settings.externalCamerasDescription')}</p>
+                  </div>
+                  {externalCameraPrinters.length > 0 && (
+                    <div className="rounded-lg border border-bambu-dark-tertiary" data-testid="external-camera-list">
+                      {externalCameraPrinters.map(printer => {
+                        const isOpen = openCameraPrinterId === printer.id;
+                        const summary = externalCameraSummary(printer);
+                        return (
+                          <div key={printer.id} className="border-b border-bambu-dark-tertiary last:border-b-0">
+                            <div className="flex items-center gap-2 px-2 py-1.5">
+                              <button
+                                type="button"
+                                onClick={() => setOpenCameraPrinterId(isOpen ? null : printer.id)}
+                                aria-expanded={isOpen}
+                                aria-label={`${t('settings.cameraShowSettings')}: ${printer.name}`}
+                                className="flex flex-1 min-w-0 items-center gap-2 text-left"
+                              >
+                                <span className="min-w-0 flex-1">
+                                  <span className="block text-sm text-white truncate">{printer.name}</span>
+                                  <span className="block text-[11px] text-bambu-gray truncate" title={summary}>{summary}</span>
+                                </span>
+                                <ChevronDown className={`w-4 h-4 shrink-0 text-bambu-gray transition-transform ${isOpen ? 'rotate-180' : ''}`} />
+                              </button>
+                              <button
+                                type="button"
+                                onClick={() => {
+                                  handleUpdatePrinterCamera(printer.id, { enabled: false });
+                                  if (isOpen) setOpenCameraPrinterId(null);
+                                }}
+                                aria-label={t('settings.externalCameraRemove', { name: printer.name })}
+                                title={t('settings.externalCameraRemove', { name: printer.name })}
+                                className="p-1 rounded text-bambu-gray hover:text-red-400"
+                              >
+                                <Trash2 className="w-3.5 h-3.5" />
+                              </button>
+                            </div>
 
 
-                          {printer.external_camera_enabled && (
-                            <div className="space-y-2 mt-2">
-                              <input
-                                type="text"
-                                placeholder={printer.external_camera_type === 'usb' ? t('settings.cameraPlaceholderUsb') : t('settings.cameraPlaceholderUrl')}
-                                value={localCameraUrls[printer.id] ?? printer.external_camera_url ?? ''}
-                                onChange={(e) => handleCameraUrlChange(printer.id, e.target.value)}
-                                className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
-                              />
-                              <div className="flex gap-2">
-                                <select
-                                  value={printer.external_camera_type || 'mjpeg'}
-                                  onChange={(e) => handleUpdatePrinterCamera(printer.id, { type: e.target.value })}
-                                  className="flex-1 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
-                                >
-                                  <option value="mjpeg">{t('settings.cameraTypeMjpeg')}</option>
-                                  <option value="rtsp">{t('settings.cameraTypeRtsp')}</option>
-                                  <option value="snapshot">{t('settings.cameraTypeSnapshot')}</option>
-                                  <option value="usb">{t('settings.cameraTypeUsb')}</option>
-                                </select>
-                                <Button
-                                  size="sm"
-                                  variant="secondary"
-                                  onClick={() => handleTestExternalCamera(printer.id, localCameraUrls[printer.id] ?? printer.external_camera_url ?? '', printer.external_camera_type || 'mjpeg')}
-                                  disabled={extCameraTestLoading[printer.id] || !(localCameraUrls[printer.id] ?? printer.external_camera_url)}
-                                >
-                                  {extCameraTestLoading[printer.id] ? (
-                                    <Loader2 className="w-4 h-4 animate-spin" />
-                                  ) : (
-                                    t('settings.test')
-                                  )}
-                                </Button>
-                              </div>
-                              {extCameraTestResults[printer.id] && (
-                                <div className={`text-xs flex items-center gap-1 ${extCameraTestResults[printer.id]?.success ? 'text-green-500' : 'text-red-500'}`}>
-                                  {extCameraTestResults[printer.id]?.success ? (
-                                    <>
-                                      <CheckCircle className="w-3 h-3" />
-                                      {t('settings.connected')}{extCameraTestResults[printer.id]?.resolution && ` (${extCameraTestResults[printer.id]?.resolution})`}
-                                    </>
-                                  ) : (
-                                    <>
-                                      <XCircle className="w-3 h-3" />
-                                      {extCameraTestResults[printer.id]?.error || t('settings.toast.connectionFailed')}
-                                    </>
-                                  )}
-                                </div>
-                              )}
-                              {(printer.external_camera_type === 'mjpeg' || printer.external_camera_type === 'rtsp' || printer.external_camera_type === 'usb') && (
-                                <div className="space-y-1">
-                                  <label className="text-xs text-bambu-gray">{t('settings.cameraSnapshotUrl', 'Snapshot URL (optional)')}</label>
-                                  <div className="flex gap-2">
-                                    <input
-                                      type="text"
-                                      placeholder={t('settings.cameraSnapshotUrlPlaceholder', 'http://192.168.1.61:1984/api/frame.jpeg?src=printer')}
-                                      value={localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url ?? ''}
-                                      onChange={(e) => handleSnapshotUrlChange(printer.id, e.target.value)}
-                                      className="flex-1 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
-                                    />
-                                    <Button
-                                      size="sm"
-                                      variant="secondary"
-                                      onClick={() => handleTestExternalCamera(printer.id, localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url ?? '', 'snapshot')}
-                                      disabled={extCameraTestLoading[printer.id] || !(localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url)}
-                                    >
-                                      {extCameraTestLoading[printer.id] ? (
-                                        <Loader2 className="w-4 h-4 animate-spin" />
-                                      ) : (
-                                        t('settings.test')
-                                      )}
-                                    </Button>
-                                  </div>
-                                  <p className="text-xs text-bambu-gray opacity-75">
-                                    {t('settings.cameraSnapshotUrlHelp', 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.')}
-                                  </p>
-                                </div>
-                              )}
-                              <div className="flex items-center gap-2">
-                                <label className="text-xs text-bambu-gray">{t('settings.cameraRotation')}</label>
+                            {isOpen && (
+                          <div className="space-y-2 px-2 pb-3 pt-1 bg-bambu-dark/40">
+                            <input
+                              type="text"
+                              aria-label={`${t('settings.cameraPlaceholderUrl')}: ${printer.name}`}
+                              placeholder={printer.external_camera_type === 'usb' ? t('settings.cameraPlaceholderUsb') : t('settings.cameraPlaceholderUrl')}
+                              value={localCameraUrls[printer.id] ?? printer.external_camera_url ?? ''}
+                              onChange={(e) => handleCameraUrlChange(printer.id, e.target.value)}
+                              className="w-full px-2 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
+                            />
+                            <div className="flex flex-wrap items-center gap-2">
+                              <select
+                                value={printer.external_camera_type || 'mjpeg'}
+                                onChange={(e) => handleUpdatePrinterCamera(printer.id, { type: e.target.value })}
+                                className="flex-1 min-w-0 px-2 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
+                              >
+                                <option value="mjpeg">{t('settings.cameraTypeMjpeg')}</option>
+                                <option value="rtsp">{t('settings.cameraTypeRtsp')}</option>
+                                <option value="snapshot">{t('settings.cameraTypeSnapshot')}</option>
+                                <option value="usb">{t('settings.cameraTypeUsb')}</option>
+                              </select>
+                              <label className="flex items-center gap-1 text-xs text-bambu-gray">
+                                {t('settings.cameraRotation')}
                                 <select
                                 <select
                                   value={printer.camera_rotation || 0}
                                   value={printer.camera_rotation || 0}
                                   onChange={(e) => handleUpdatePrinterCamera(printer.id, { rotation: parseInt(e.target.value) })}
                                   onChange={(e) => handleUpdatePrinterCamera(printer.id, { rotation: parseInt(e.target.value) })}
-                                  className="px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs focus:border-bambu-green focus:outline-none"
+                                  className="px-1.5 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs focus:border-bambu-green focus:outline-none"
                                 >
                                 >
                                   <option value={0}>0°</option>
                                   <option value={0}>0°</option>
                                   <option value={90}>90°</option>
                                   <option value={90}>90°</option>
                                   <option value={180}>180°</option>
                                   <option value={180}>180°</option>
                                   <option value={270}>270°</option>
                                   <option value={270}>270°</option>
                                 </select>
                                 </select>
-                              </div>
+                              </label>
+                              <Button
+                                size="sm"
+                                variant="secondary"
+                                onClick={() => handleTestExternalCamera(printer.id, localCameraUrls[printer.id] ?? printer.external_camera_url ?? '', printer.external_camera_type || 'mjpeg')}
+                                disabled={extCameraTestLoading[printer.id] || !(localCameraUrls[printer.id] ?? printer.external_camera_url)}
+                              >
+                                {extCameraTestLoading[printer.id] ? (
+                                  <Loader2 className="w-4 h-4 animate-spin" />
+                                ) : (
+                                  t('settings.test')
+                                )}
+                              </Button>
                             </div>
                             </div>
-                          )}
-                        </div>
-                      ))}
+                            {extCameraTestResults[printer.id] && (
+                              <div className={`text-xs flex items-center gap-1 ${extCameraTestResults[printer.id]?.success ? 'text-green-500' : 'text-red-500'}`}>
+                                {extCameraTestResults[printer.id]?.success ? (
+                                  <>
+                                    <CheckCircle className="w-3 h-3" />
+                                    {t('settings.connected')}{extCameraTestResults[printer.id]?.resolution && ` (${extCameraTestResults[printer.id]?.resolution})`}
+                                  </>
+                                ) : (
+                                  <>
+                                    <XCircle className="w-3 h-3" />
+                                    {extCameraTestResults[printer.id]?.error || t('settings.toast.connectionFailed')}
+                                  </>
+                                )}
+                              </div>
+                            )}
+                            {(printer.external_camera_type === 'mjpeg' || printer.external_camera_type === 'rtsp' || printer.external_camera_type === 'usb') && (
+                              <div className="space-y-1">
+                                <label className="text-xs text-bambu-gray flex items-center gap-1">
+                                  {t('settings.cameraSnapshotUrl', 'Snapshot URL (optional)')}
+                                  <span title={t('settings.cameraSnapshotUrlHelp')} className="cursor-help" aria-label={t('settings.cameraSnapshotUrlHelp')}>
+                                    <Info className="w-3 h-3" />
+                                  </span>
+                                </label>
+                                <div className="flex gap-2">
+                                  <input
+                                    type="text"
+                                    placeholder={t('settings.cameraSnapshotUrlPlaceholder', 'http://192.168.1.61:1984/api/frame.jpeg?src=printer')}
+                                    value={localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url ?? ''}
+                                    onChange={(e) => handleSnapshotUrlChange(printer.id, e.target.value)}
+                                    className="flex-1 min-w-0 px-2 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
+                                  />
+                                  <Button
+                                    size="sm"
+                                    variant="secondary"
+                                    onClick={() => handleTestExternalCamera(printer.id, localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url ?? '', 'snapshot')}
+                                    disabled={extCameraTestLoading[printer.id] || !(localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url)}
+                                  >
+                                    {extCameraTestLoading[printer.id] ? (
+                                      <Loader2 className="w-4 h-4 animate-spin" />
+                                    ) : (
+                                      t('settings.test')
+                                    )}
+                                  </Button>
+                                </div>
+                              </div>
+                            )}
+                          </div>
+                            )}
+                          </div>
+                        );
+                      })}
                     </div>
                     </div>
+                  )}
+                  {printers && printers.length > 0 ? (
+                    <PrinterSearchPicker
+                      printers={printers.filter(p => !p.external_camera_enabled)}
+                      onPick={(p) => {
+                        handleUpdatePrinterCamera(p.id, { enabled: true });
+                        // A camera just added needs its URL next.
+                        setOpenCameraPrinterId(p.id);
+                      }}
+                      label={t('settings.externalCameraAdd')}
+                    />
                   ) : (
                   ) : (
                     <p className="text-xs text-bambu-gray italic">{t('settings.noPrintersConfigured')}</p>
                     <p className="text-xs text-bambu-gray italic">{t('settings.noPrintersConfigured')}</p>
                   )}
                   )}
-                </div>
+                </section>
               </CardContent>
               </CardContent>
             </Card>
             </Card>
 
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ImagePreviewModal-BnY1sAGY.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/PdfPreviewModal-CHu3pQMX.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpreadsheetPreviewModal-Bao39I9F.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
static/assets/index-D1FhwA-P.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/index-D1nJM-VU.css


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
static/assets/index-J6DEE_YS.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/pdf-CnF50rQ7.js


+ 2 - 2
static/index.html

@@ -26,9 +26,9 @@
 
 
     <!-- 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-iHfLBieq.js"></script>
+    <script type="module" crossorigin src="/assets/index-J6DEE_YS.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
-    <link rel="stylesheet" crossorigin href="/assets/index-D1nJM-VU.css">
+    <link rel="stylesheet" crossorigin href="/assets/index-D1FhwA-P.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов