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

Add feature: Attach camera snapshots to notifications (issue #3089) (#3199)

Benji 1 день назад
Родитель
Сommit
7f64e2ba8b
35 измененных файлов с 1358 добавлено и 72 удалено
  1. 9 0
      backend/app/api/routes/notification_templates.py
  2. 27 3
      backend/app/api/routes/notifications.py
  3. 10 0
      backend/app/core/database.py
  4. 56 27
      backend/app/main.py
  5. 5 0
      backend/app/models/notification.py
  6. 7 0
      backend/app/schemas/notification.py
  7. 55 3
      backend/app/schemas/notification_template.py
  8. 60 10
      backend/app/services/email_service.py
  9. 200 20
      backend/app/services/notification_service.py
  10. 10 3
      backend/app/services/obico_actions.py
  11. 7 3
      backend/app/services/obico_detection.py
  12. 104 0
      backend/app/utils/notification_photos.py
  13. 2 0
      backend/tests/conftest.py
  14. 467 0
      backend/tests/unit/services/test_notification_photo_attachments.py
  15. 148 0
      backend/tests/unit/test_plate_check_chamber_light.py
  16. 5 0
      backend/tests/unit/test_route_auth_coverage.py
  17. 5 0
      frontend/src/api/client.ts
  18. 23 2
      frontend/src/components/AddNotificationModal.tsx
  19. 22 1
      frontend/src/components/NotificationProviderCard.tsx
  20. 16 0
      frontend/src/components/NotificationTemplateEditor.tsx
  21. 8 0
      frontend/src/i18n/locales/de.ts
  22. 8 0
      frontend/src/i18n/locales/en.ts
  23. 8 0
      frontend/src/i18n/locales/es.ts
  24. 8 0
      frontend/src/i18n/locales/fr.ts
  25. 8 0
      frontend/src/i18n/locales/it.ts
  26. 8 0
      frontend/src/i18n/locales/ja.ts
  27. 8 0
      frontend/src/i18n/locales/ko.ts
  28. 8 0
      frontend/src/i18n/locales/nl.ts
  29. 8 0
      frontend/src/i18n/locales/pt-BR.ts
  30. 8 0
      frontend/src/i18n/locales/ru.ts
  31. 8 0
      frontend/src/i18n/locales/sv.ts
  32. 8 0
      frontend/src/i18n/locales/tr.ts
  33. 8 0
      frontend/src/i18n/locales/uk.ts
  34. 8 0
      frontend/src/i18n/locales/zh-CN.ts
  35. 8 0
      frontend/src/i18n/locales/zh-TW.ts

+ 9 - 0
backend/app/api/routes/notification_templates.py

@@ -11,6 +11,7 @@ from backend.app.models.notification_template import DEFAULT_TEMPLATES, Notifica
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.notification_template import (
 from backend.app.schemas.notification_template import (
     EVENT_VARIABLES,
     EVENT_VARIABLES,
+    PHOTO_CAPABLE_EVENTS,
     SAMPLE_DATA,
     SAMPLE_DATA,
     EventVariablesResponse,
     EventVariablesResponse,
     NotificationTemplateResponse,
     NotificationTemplateResponse,
@@ -33,8 +34,12 @@ EVENT_NAMES = {
     "billing_charge_failed": "Billing Charge Failed",
     "billing_charge_failed": "Billing Charge Failed",
     "printer_offline": "Printer Offline",
     "printer_offline": "Printer Offline",
     "printer_error": "Printer Error",
     "printer_error": "Printer Error",
+    "ai_failure_detection": "AI Failure Detection",
+    "plate_not_empty": "Plate Not Empty",
+    "plate_clear_required": "Plate Clear Required",
     "filament_low": "Filament Low",
     "filament_low": "Filament Low",
     "maintenance_due": "Maintenance Due",
     "maintenance_due": "Maintenance Due",
+    "first_layer_complete": "First Layer Complete",
     "test": "Test Notification",
     "test": "Test Notification",
     # Queue notifications
     # Queue notifications
     "queue_job_added": "Queue Job Added",
     "queue_job_added": "Queue Job Added",
@@ -44,6 +49,9 @@ EVENT_NAMES = {
     "queue_job_skipped": "Queue Job Skipped",
     "queue_job_skipped": "Queue Job Skipped",
     "queue_job_failed": "Queue Job Failed",
     "queue_job_failed": "Queue Job Failed",
     "queue_completed": "Queue Completed",
     "queue_completed": "Queue Completed",
+    # Inventory stock alerts
+    "stock_reorder_alert": "Stock Reorder Alert",
+    "stock_break_alert": "Stock Break Alert",
     # User management
     # User management
     "user_created": "Welcome Email",
     "user_created": "Welcome Email",
     "password_reset": "Password Reset",
     "password_reset": "Password Reset",
@@ -76,6 +84,7 @@ async def get_variables(
             event_type=event_type,
             event_type=event_type,
             event_name=EVENT_NAMES.get(event_type, event_type),
             event_name=EVENT_NAMES.get(event_type, event_type),
             variables=variables,
             variables=variables,
+            supports_photo=event_type in PHOTO_CAPABLE_EVENTS,
         )
         )
         for event_type, variables in EVENT_VARIABLES.items()
         for event_type, variables in EVENT_VARIABLES.items()
     ]
     ]

+ 27 - 3
backend/app/api/routes/notifications.py

@@ -7,6 +7,7 @@ from collections import defaultdict, deque
 from datetime import datetime, timedelta, timezone
 from datetime import datetime, timedelta, timezone
 
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.responses import FileResponse
 from sqlalchemy import delete, desc, func, select
 from sqlalchemy import delete, desc, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
@@ -29,6 +30,7 @@ from backend.app.schemas.notification import (
 )
 )
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
 from backend.app.services.telegram_reactions import telegram_reaction_poller
 from backend.app.services.telegram_reactions import telegram_reaction_poller
+from backend.app.utils.notification_photos import find_notification_photo
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -55,6 +57,7 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "provider_type": provider.provider_type,
         "provider_type": provider.provider_type,
         "enabled": provider.enabled,
         "enabled": provider.enabled,
         "config": json.loads(provider.config) if isinstance(provider.config, str) else provider.config,
         "config": json.loads(provider.config) if isinstance(provider.config, str) else provider.config,
+        "attach_photo": provider.attach_photo,
         # Print lifecycle events
         # Print lifecycle events
         "on_print_start": provider.on_print_start,
         "on_print_start": provider.on_print_start,
         "on_print_complete": provider.on_print_complete,
         "on_print_complete": provider.on_print_complete,
@@ -155,6 +158,7 @@ async def create_notification_provider(
         provider_type=provider_data.provider_type.value,
         provider_type=provider_data.provider_type.value,
         enabled=provider_data.enabled,
         enabled=provider_data.enabled,
         config=json.dumps(provider_data.config),
         config=json.dumps(provider_data.config),
+        attach_photo=provider_data.attach_photo,
         # Print lifecycle events
         # Print lifecycle events
         on_print_start=provider_data.on_print_start,
         on_print_start=provider_data.on_print_start,
         on_print_complete=provider_data.on_print_complete,
         on_print_complete=provider_data.on_print_complete,
@@ -235,7 +239,7 @@ async def test_notification_config(
 ):
 ):
     """Test notification configuration before saving."""
     """Test notification configuration before saving."""
     success, message = await notification_service.send_test_notification(
     success, message = await notification_service.send_test_notification(
-        test_request.provider_type.value, test_request.config, db
+        test_request.provider_type.value, test_request.config, db, attach_photo=test_request.attach_photo
     )
     )
 
 
     return NotificationTestResponse(success=success, message=message)
     return NotificationTestResponse(success=success, message=message)
@@ -259,7 +263,9 @@ async def test_all_notification_providers(
 
 
     for provider in providers:
     for provider in providers:
         config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
         config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
-        success, message = await notification_service.send_test_notification(provider.provider_type, config, db)
+        success, message = await notification_service.send_test_notification(
+            provider.provider_type, config, db, attach_photo=provider.attach_photo
+        )
 
 
         # Update provider status
         # Update provider status
         if success:
         if success:
@@ -421,6 +427,22 @@ async def clear_notification_logs(
     return {"deleted": deleted_count, "message": f"Deleted {deleted_count} logs older than {older_than_days} days"}
     return {"deleted": deleted_count, "message": f"Deleted {deleted_count} logs older than {older_than_days} days"}
 
 
 
 
+@router.get("/photos/{filename}")
+async def get_notification_photo(filename: str):
+    """Serve an ad-hoc notification snapshot to HA, Bark or Slack.
+
+    They fetch this URL themselves with no session, so the unguessable
+    filename is the credential and opens this one photo only -- see
+    backend/app/utils/notification_photos.py. Anything that isn't a live
+    photo of exactly that shape is a 404.
+    """
+    photo_path = find_notification_photo(filename)
+    if photo_path is None:
+        raise HTTPException(404, "Photo not found")
+
+    return FileResponse(path=photo_path, media_type="image/jpeg", headers={"Cache-Control": "private, no-store"})
+
+
 # ============================================================================
 # ============================================================================
 # Provider Instance Routes (parameterized - must come LAST)
 # Provider Instance Routes (parameterized - must come LAST)
 # ============================================================================
 # ============================================================================
@@ -573,7 +595,9 @@ async def test_notification_provider(
         raise HTTPException(status_code=404, detail="Notification provider not found")
         raise HTTPException(status_code=404, detail="Notification provider not found")
 
 
     config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
     config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
-    success, message = await notification_service.send_test_notification(provider.provider_type, config, db)
+    success, message = await notification_service.send_test_notification(
+        provider.provider_type, config, db, attach_photo=provider.attach_photo
+    )
 
 
     # Update provider status
     # Update provider status
     if success:
     if success:

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

@@ -5186,6 +5186,16 @@ async def run_migrations(conn):
     # keep showing their English error_message.
     # keep showing their English error_message.
     await _safe_execute(conn, "ALTER TABLE scheduled_dryings ADD COLUMN error_code VARCHAR(32)")
     await _safe_execute(conn, "ALTER TABLE scheduled_dryings ADD COLUMN error_code VARCHAR(32)")
 
 
+    # Migration: per-provider photo attachment opt-out. Defaults TRUE so
+    # existing providers keep attaching snapshots exactly as before. The
+    # backfill covers a table create_all() already gave the column (the ALTER
+    # is then swallowed and existing rows keep NULL, which reads as off).
+    await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN attach_photo BOOLEAN DEFAULT TRUE")
+    async with conn.begin_nested():
+        await conn.execute(
+            text("UPDATE notification_providers SET attach_photo = :on WHERE attach_photo IS NULL"), {"on": True}
+        )
+
 
 
 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).

+ 56 - 27
backend/app/main.py

@@ -3323,6 +3323,8 @@ async def _dispatch_user_print_email(
     printer_name: str,
     printer_name: str,
     filename: str,
     filename: str,
     db,
     db,
+    image_data: bytes | None = None,
+    finish_photo_url: str | None = None,
 ) -> None:
 ) -> None:
     """Send a user-specific print-completion email based on print status.
     """Send a user-specific print-completion email based on print status.
 
 
@@ -3349,6 +3351,8 @@ async def _dispatch_user_print_email(
         printer_name=printer_name,
         printer_name=printer_name,
         filename=filename,
         filename=filename,
         db=db,
         db=db,
+        image_data=image_data,
+        finish_photo_url=finish_photo_url,
     )
     )
 
 
 
 
@@ -4014,36 +4018,58 @@ async def on_print_start(printer_id: int, data: dict):
                         # Wait for light to physically turn on and camera to adjust exposure
                         # Wait for light to physically turn on and camera to adjust exposure
                         await asyncio.sleep(2.5)
                         await asyncio.sleep(2.5)
 
 
-                logger.info("[PLATE CHECK] Running plate detection for printer %s", printer_id)
-                plate_result = await check_plate_empty(
-                    printer_id=printer_id,
-                    ip_address=printer.ip_address,
-                    access_code=printer.access_code,
-                    model=printer.model,
-                    include_debug_image=False,
-                    external_camera_url=printer.external_camera_url,
-                    external_camera_type=printer.external_camera_type,
-                    use_external=printer.external_camera_enabled,
-                    roi=roi,
-                    external_camera_snapshot_url=printer.external_camera_snapshot_url,
-                )
+                plate_photo_data = None
+                objects_detected = False
+                try:
+                    logger.info("[PLATE CHECK] Running plate detection for printer %s", printer_id)
+                    plate_result = await check_plate_empty(
+                        printer_id=printer_id,
+                        ip_address=printer.ip_address,
+                        access_code=printer.access_code,
+                        model=printer.model,
+                        include_debug_image=False,
+                        external_camera_url=printer.external_camera_url,
+                        external_camera_type=printer.external_camera_type,
+                        use_external=printer.external_camera_enabled,
+                        roi=roi,
+                        external_camera_snapshot_url=printer.external_camera_snapshot_url,
+                    )
 
 
-                # Restore chamber light to original state
-                if light_was_off and client:
-                    logger.info("[PLATE CHECK] Restoring chamber light to off for printer %s", printer_id)
-                    client.set_chamber_light(False)
+                    objects_detected = not plate_result.needs_calibration and not plate_result.is_empty
+                    if objects_detected:
+                        # Objects detected - pause the print!
+                        logger.warning(
+                            f"[PLATE CHECK] Objects detected on plate for printer {printer_id}! "
+                            f"Confidence: {plate_result.confidence:.0%}, Diff: {plate_result.difference_percent:.1f}%"
+                        )
+                        pause_client = printer_manager.get_client(printer_id)
+                        if pause_client:
+                            pause_client.pause_print()
+                            logger.info("[PLATE CHECK] Print paused for printer %s", printer_id)
 
 
-                if not plate_result.needs_calibration and not plate_result.is_empty:
-                    # Objects detected - pause the print!
-                    logger.warning(
-                        f"[PLATE CHECK] Objects detected on plate for printer {printer_id}! "
-                        f"Confidence: {plate_result.confidence:.0%}, Diff: {plate_result.difference_percent:.1f}%"
-                    )
-                    client = printer_manager.get_client(printer_id)
-                    if client:
-                        client.pause_print()
-                        logger.info("[PLATE CHECK] Print paused for printer %s", printer_id)
+                        # Snapshot while the light's still on — restoring it first
+                        # would leave the notification with a dark photo.
+                        try:
+                            plate_photo_data = await _capture_snapshot_for_notification(printer_id, printer, logger)
+                        except Exception as snap_err:
+                            logger.warning(
+                                "[PLATE CHECK] Failed to capture snapshot for printer %s: %s", printer_id, snap_err
+                            )
+                finally:
+                    # Restore chamber light to original state as soon as the
+                    # camera is done with it, whatever happened above.
+                    if light_was_off and client:
+                        logger.info("[PLATE CHECK] Restoring chamber light to off for printer %s", printer_id)
+                        try:
+                            client.set_chamber_light(False)
+                        except Exception as light_err:
+                            logger.warning(
+                                "[PLATE CHECK] Failed to restore chamber light for printer %s: %s",
+                                printer_id,
+                                light_err,
+                            )
 
 
+                if objects_detected:
                     # Send notification about plate not empty
                     # Send notification about plate not empty
                     await ws_manager.broadcast(
                     await ws_manager.broadcast(
                         {
                         {
@@ -4061,6 +4087,7 @@ async def on_print_start(printer_id: int, data: dict):
                             printer_name=printer.name,
                             printer_name=printer.name,
                             db=db,
                             db=db,
                             difference_percent=plate_result.difference_percent,
                             difference_percent=plate_result.difference_percent,
+                            image_data=plate_photo_data,
                         )
                         )
                     except Exception as notif_err:
                     except Exception as notif_err:
                         logger.warning("[PLATE CHECK] Failed to send notification: %s", notif_err)
                         logger.warning("[PLATE CHECK] Failed to send notification: %s", notif_err)
@@ -8236,6 +8263,8 @@ async def on_print_complete(printer_id: int, data: dict):
                         printer_name,
                         printer_name,
                         raw_filename,
                         raw_filename,
                         db,
                         db,
+                        image_data=archive_data.get("image_data"),
+                        finish_photo_url=archive_data.get("finish_photo_url"),
                     )
                     )
 
 
                 logger.info("[NOTIFY-BG] Completed")
                 logger.info("[NOTIFY-BG] Completed")

+ 5 - 0
backend/app/models/notification.py

@@ -59,6 +59,11 @@ class NotificationProvider(Base):
     # Provider-specific configuration stored as JSON string
     # Provider-specific configuration stored as JSON string
     config = Column(Text, nullable=False)
     config = Column(Text, nullable=False)
 
 
+    # Attach a camera snapshot when one was captured for the event. Defaults
+    # True so ntfy/Pushover/Telegram/Discord keep behaving as they always
+    # did; this is the opt-out for whoever doesn't want it.
+    attach_photo = Column(Boolean, default=True)
+
     # Event triggers - print lifecycle
     # Event triggers - print lifecycle
     on_print_start = Column(Boolean, default=False)
     on_print_start = Column(Boolean, default=False)
     on_print_complete = Column(Boolean, default=True)
     on_print_complete = Column(Boolean, default=True)

+ 7 - 0
backend/app/schemas/notification.py

@@ -29,6 +29,9 @@ class NotificationProviderBase(BaseModel):
     provider_type: ProviderType = Field(..., description="Type of notification provider")
     provider_type: ProviderType = Field(..., description="Type of notification provider")
     enabled: bool = Field(default=True, description="Whether notifications are enabled")
     enabled: bool = Field(default=True, description="Whether notifications are enabled")
     config: dict[str, Any] = Field(..., description="Provider-specific configuration")
     config: dict[str, Any] = Field(..., description="Provider-specific configuration")
+    attach_photo: bool = Field(
+        default=True, description="Attach a camera snapshot to this provider's notifications when one is available"
+    )
 
 
     # Event triggers - print lifecycle
     # Event triggers - print lifecycle
     on_print_start: bool = Field(default=False, description="Notify on print start")
     on_print_start: bool = Field(default=False, description="Notify on print start")
@@ -166,6 +169,7 @@ class NotificationProviderUpdate(BaseModel):
     provider_type: ProviderType | None = None
     provider_type: ProviderType | None = None
     enabled: bool | None = None
     enabled: bool | None = None
     config: dict[str, Any] | None = None
     config: dict[str, Any] | None = None
+    attach_photo: bool | None = None
 
 
     # Event triggers - print lifecycle
     # Event triggers - print lifecycle
     on_print_start: bool | None = None
     on_print_start: bool | None = None
@@ -332,6 +336,9 @@ class NotificationTestRequest(BaseModel):
 
 
     provider_type: ProviderType
     provider_type: ProviderType
     config: dict[str, Any]
     config: dict[str, Any]
+    attach_photo: bool = Field(
+        default=True, description="Include a sample photo in the test, mirroring the provider's own toggle"
+    )
 
 
 
 
 class NotificationTestResponse(BaseModel):
 class NotificationTestResponse(BaseModel):

+ 55 - 3
backend/app/schemas/notification_template.py

@@ -76,6 +76,9 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     "billing_charge_failed": ["printer", "filename", "archive_id", "error", "timestamp", "app_name"],
     "billing_charge_failed": ["printer", "filename", "archive_id", "error", "timestamp", "app_name"],
     "printer_offline": ["printer", "timestamp", "app_name"],
     "printer_offline": ["printer", "timestamp", "app_name"],
     "printer_error": ["printer", "error_type", "error_detail", "timestamp", "app_name"],
     "printer_error": ["printer", "error_type", "error_detail", "timestamp", "app_name"],
+    "ai_failure_detection": ["printer", "task_name", "confidence", "action", "timestamp", "app_name"],
+    "plate_not_empty": ["printer", "difference_percent", "timestamp", "app_name"],
+    "plate_clear_required": ["printer", "timestamp", "app_name"],
     "filament_low": ["printer", "slot", "remaining_percent", "color", "timestamp", "app_name"],
     "filament_low": ["printer", "slot", "remaining_percent", "color", "timestamp", "app_name"],
     "maintenance_due": ["printer", "items", "timestamp", "app_name"],
     "maintenance_due": ["printer", "items", "timestamp", "app_name"],
     "ams_humidity_high": ["printer", "ams_label", "humidity", "threshold", "timestamp", "app_name"],
     "ams_humidity_high": ["printer", "ams_label", "humidity", "threshold", "timestamp", "app_name"],
@@ -92,6 +95,7 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     "bed_cooled": ["printer", "bed_temp", "threshold", "filename", "timestamp", "app_name"],
     "bed_cooled": ["printer", "bed_temp", "threshold", "filename", "timestamp", "app_name"],
     "ha_sensor_alert": ["printer", "sensor", "state", "timestamp", "app_name"],
     "ha_sensor_alert": ["printer", "sensor", "state", "timestamp", "app_name"],
     "location_ha_sensor_alert": ["location", "sensor", "state", "timestamp", "app_name"],
     "location_ha_sensor_alert": ["location", "sensor", "state", "timestamp", "app_name"],
+    "first_layer_complete": ["printer", "filename", "total_layers", "timestamp", "app_name"],
     "test": ["app_name", "timestamp"],
     "test": ["app_name", "timestamp"],
     # Queue notifications
     # Queue notifications
     "queue_job_added": ["job_name", "target", "timestamp", "app_name"],
     "queue_job_added": ["job_name", "target", "timestamp", "app_name"],
@@ -129,9 +133,27 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     "password_reset": ["username", "password", "login_url", "app_name", "timestamp"],
     "password_reset": ["username", "password", "login_url", "app_name", "timestamp"],
     # User email print notifications
     # User email print notifications
     "user_print_start": ["username", "printer", "filename", "timestamp", "app_name"],
     "user_print_start": ["username", "printer", "filename", "timestamp", "app_name"],
-    "user_print_complete": ["username", "printer", "filename", "timestamp", "app_name"],
-    "user_print_failed": ["username", "printer", "filename", "timestamp", "app_name"],
-    "user_print_stopped": ["username", "printer", "filename", "timestamp", "app_name"],
+    "user_print_complete": ["username", "printer", "filename", "finish_photo_url", "timestamp", "app_name"],
+    "user_print_failed": ["username", "printer", "filename", "finish_photo_url", "timestamp", "app_name"],
+    "user_print_stopped": ["username", "printer", "filename", "finish_photo_url", "timestamp", "app_name"],
+}
+
+# Events whose real notification can carry a camera snapshot — mirrors which
+# NotificationService.on_* methods accept image_data.
+PHOTO_CAPABLE_EVENTS: set[str] = {
+    "print_start",
+    "print_complete",
+    "print_failed",
+    "print_stopped",
+    "print_progress",
+    "printer_error",
+    "ai_failure_detection",
+    "plate_not_empty",
+    "first_layer_complete",
+    "user_print_complete",
+    "user_print_failed",
+    "user_print_stopped",
+    "test",
 }
 }
 
 
 # Sample data for previewing templates
 # Sample data for previewing templates
@@ -213,6 +235,25 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "timestamp": "2024-01-15 14:30",
         "timestamp": "2024-01-15 14:30",
         "app_name": "Bambuddy",
         "app_name": "Bambuddy",
     },
     },
+    "ai_failure_detection": {
+        "printer": "Bambu X1C",
+        "task_name": "Benchy.3mf",
+        "confidence": "0.87",
+        "action": "pause",
+        "timestamp": "2024-01-15 15:10",
+        "app_name": "Bambuddy",
+    },
+    "plate_not_empty": {
+        "printer": "Bambu X1C",
+        "difference_percent": "12.4",
+        "timestamp": "2024-01-15 09:00",
+        "app_name": "Bambuddy",
+    },
+    "plate_clear_required": {
+        "printer": "Bambu X1C",
+        "timestamp": "2024-01-15 15:48",
+        "app_name": "Bambuddy",
+    },
     "filament_low": {
     "filament_low": {
         "printer": "Bambu X1C",
         "printer": "Bambu X1C",
         "slot": "1",
         "slot": "1",
@@ -274,6 +315,13 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "timestamp": "2024-01-15 14:30",
         "timestamp": "2024-01-15 14:30",
         "app_name": "Bambuddy",
         "app_name": "Bambuddy",
     },
     },
+    "first_layer_complete": {
+        "printer": "Bambu X1C",
+        "filename": "Benchy.3mf",
+        "total_layers": "184",
+        "timestamp": "2024-01-15 14:35",
+        "app_name": "Bambuddy",
+    },
     "test": {
     "test": {
         "app_name": "Bambuddy",
         "app_name": "Bambuddy",
         "timestamp": "2024-01-15 14:30",
         "timestamp": "2024-01-15 14:30",
@@ -376,6 +424,7 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "username": "john_doe",
         "username": "john_doe",
         "printer": "Bambu X1C",
         "printer": "Bambu X1C",
         "filename": "Benchy.3mf",
         "filename": "Benchy.3mf",
+        "finish_photo_url": "/api/v1/archives/123/photos/finish_20240115_154800_abc12345.jpg",
         "timestamp": "2024-01-15 15:48",
         "timestamp": "2024-01-15 15:48",
         "app_name": "Bambuddy",
         "app_name": "Bambuddy",
     },
     },
@@ -383,6 +432,7 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "username": "john_doe",
         "username": "john_doe",
         "printer": "Bambu X1C",
         "printer": "Bambu X1C",
         "filename": "Benchy.3mf",
         "filename": "Benchy.3mf",
+        "finish_photo_url": "/api/v1/archives/123/photos/finish_20240115_151500_def67890.jpg",
         "timestamp": "2024-01-15 15:15",
         "timestamp": "2024-01-15 15:15",
         "app_name": "Bambuddy",
         "app_name": "Bambuddy",
     },
     },
@@ -390,6 +440,7 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "username": "john_doe",
         "username": "john_doe",
         "printer": "Bambu X1C",
         "printer": "Bambu X1C",
         "filename": "Benchy.3mf",
         "filename": "Benchy.3mf",
+        "finish_photo_url": "/api/v1/archives/123/photos/finish_20240115_150000_ghi11223.jpg",
         "timestamp": "2024-01-15 15:15",
         "timestamp": "2024-01-15 15:15",
         "app_name": "Bambuddy",
         "app_name": "Bambuddy",
     },
     },
@@ -437,6 +488,7 @@ class EventVariablesResponse(BaseModel):
     event_type: str
     event_type: str
     event_name: str
     event_name: str
     variables: list[str]
     variables: list[str]
+    supports_photo: bool = False
 
 
 
 
 class TemplatePreviewRequest(BaseModel):
 class TemplatePreviewRequest(BaseModel):

+ 60 - 10
backend/app/services/email_service.py

@@ -9,6 +9,7 @@ import secrets
 import smtplib
 import smtplib
 import string
 import string
 from datetime import datetime, timezone
 from datetime import datetime, timezone
+from email.mime.image import MIMEImage
 from email.mime.multipart import MIMEMultipart
 from email.mime.multipart import MIMEMultipart
 from email.mime.text import MIMEText
 from email.mime.text import MIMEText
 from typing import Any
 from typing import Any
@@ -179,6 +180,8 @@ def send_email(
     subject: str,
     subject: str,
     body_text: str,
     body_text: str,
     body_html: str | None = None,
     body_html: str | None = None,
+    image_data: bytes | None = None,
+    image_cid: str = "bambuddy-inline-photo",
 ) -> None:
 ) -> None:
     """Send an email using SMTP.
     """Send an email using SMTP.
 
 
@@ -188,21 +191,43 @@ def send_email(
         subject: Email subject
         subject: Email subject
         body_text: Plain text body
         body_text: Plain text body
         body_html: Optional HTML body
         body_html: Optional HTML body
+        image_data: Optional JPEG bytes to embed inline. The caller is
+            responsible for putting a matching ``<img src="cid:{image_cid}">``
+            in body_html — this just attaches the bytes under that Content-ID.
+        image_cid: Content-ID the embedded image is referenced by in body_html.
 
 
     Raises:
     Raises:
         Exception: If email sending fails
         Exception: If email sending fails
     """
     """
-    msg = MIMEMultipart("alternative")
-    msg["From"] = f"{smtp_settings.smtp_from_name} <{smtp_settings.smtp_from_email}>"
-    msg["To"] = to_email
-    msg["Subject"] = subject
+    if image_data and body_html:
+        # multipart/related wraps multipart/alternative so the inline image
+        # travels with the HTML part without becoming a visible attachment.
+        msg = MIMEMultipart("related")
+        msg["From"] = f"{smtp_settings.smtp_from_name} <{smtp_settings.smtp_from_email}>"
+        msg["To"] = to_email
+        msg["Subject"] = subject
+
+        alt = MIMEMultipart("alternative")
+        alt.attach(MIMEText(body_text, "plain"))
+        alt.attach(MIMEText(body_html, "html"))
+        msg.attach(alt)
+
+        img = MIMEImage(image_data, _subtype="jpeg")
+        img.add_header("Content-ID", f"<{image_cid}>")
+        img.add_header("Content-Disposition", "inline", filename="photo.jpg")
+        msg.attach(img)
+    else:
+        msg = MIMEMultipart("alternative")
+        msg["From"] = f"{smtp_settings.smtp_from_name} <{smtp_settings.smtp_from_email}>"
+        msg["To"] = to_email
+        msg["Subject"] = subject
 
 
-    # Attach plain text part
-    msg.attach(MIMEText(body_text, "plain"))
+        # Attach plain text part
+        msg.attach(MIMEText(body_text, "plain"))
 
 
-    # Attach HTML part if provided
-    if body_html:
-        msg.attach(MIMEText(body_html, "html"))
+        # Attach HTML part if provided
+        if body_html:
+            msg.attach(MIMEText(body_html, "html"))
 
 
     # Send email
     # Send email
     try:
     try:
@@ -580,6 +605,7 @@ async def send_user_print_notification(
     user_email: str,
     user_email: str,
     username: str,
     username: str,
     variables: dict,
     variables: dict,
+    image_data: bytes | None = None,
 ) -> None:
 ) -> None:
     """Send a print notification email to a user using Advanced Auth SMTP settings.
     """Send a print notification email to a user using Advanced Auth SMTP settings.
 
 
@@ -589,6 +615,10 @@ async def send_user_print_notification(
         user_email: Recipient email address
         user_email: Recipient email address
         username: Username of the recipient
         username: Username of the recipient
         variables: Template variables (printer, filename, etc.)
         variables: Template variables (printer, filename, etc.)
+        image_data: Camera snapshot bytes, if one was captured for the event.
+            Inlined only when the template explicitly references
+            {finish_photo_url} — same opt-in as the provider-based email path
+            (#1792), so a user who never asked for a photo never gets one.
     """
     """
     # Check that advanced auth is enabled (SMTP settings must be configured)
     # Check that advanced auth is enabled (SMTP settings must be configured)
     smtp_settings = await get_smtp_settings(db)
     smtp_settings = await get_smtp_settings(db)
@@ -613,8 +643,20 @@ async def send_user_print_notification(
     subject = render_template(template.title_template, all_variables)
     subject = render_template(template.title_template, all_variables)
     text_body = render_template(template.body_template, all_variables)
     text_body = render_template(template.body_template, all_variables)
 
 
+    finish_photo_url = variables.get("finish_photo_url")
+    inline_photo = bool(image_data and finish_photo_url and finish_photo_url in text_body)
+
     # Build HTML body — content comes entirely from the database template
     # Build HTML body — content comes entirely from the database template
     escaped_text_body = html.escape(text_body).replace("\n", "<br>\n")
     escaped_text_body = html.escape(text_body).replace("\n", "<br>\n")
+    if inline_photo:
+        escaped_url = html.escape(finish_photo_url)
+        img_tag = (
+            '<img src="cid:bambuddy-user-print-photo" '
+            'alt="Printer camera snapshot" '
+            'style="max-width:100%;height:auto;border:1px solid #ddd;border-radius:4px;">'
+        )
+        escaped_text_body = escaped_text_body.replace(escaped_url, img_tag)
+
     html_body = f"""<!DOCTYPE html>
     html_body = f"""<!DOCTYPE html>
 <html>
 <html>
 <head>
 <head>
@@ -633,7 +675,15 @@ async def send_user_print_notification(
 """
 """
 
 
     try:
     try:
-        send_email(smtp_settings, user_email, subject, text_body, html_body)
+        send_email(
+            smtp_settings,
+            user_email,
+            subject,
+            text_body,
+            html_body,
+            image_data=image_data if inline_photo else None,
+            image_cid="bambuddy-user-print-photo",
+        )
         logger.info("Sent %s notification email to %s", event_type, user_email)
         logger.info("Sent %s notification email to %s", event_type, user_email)
     except Exception as e:
     except Exception as e:
         logger.error("Failed to send %s notification to %s: %s", event_type, user_email, e)
         logger.error("Failed to send %s notification to %s: %s", event_type, user_email, e)

+ 200 - 20
backend/app/services/notification_service.py

@@ -26,6 +26,7 @@ from backend.app.models.notification import (
 )
 )
 from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.notification_template import NotificationTemplate
 from backend.app.services.print_confirmation import one_tap_url
 from backend.app.services.print_confirmation import one_tap_url
+from backend.app.utils.notification_photos import save_notification_photo
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -137,6 +138,41 @@ def _opaque_http_failure(response: httpx.Response, *, label: str) -> str:
     return f"HTTP {response.status_code} from the configured {label} (see server logs at debug level for details)"
     return f"HTTP {response.status_code} from the configured {label} (see server logs at debug level for details)"
 
 
 
 
+_sample_notification_image: bytes | None = None
+_sample_notification_image_loaded = False
+
+
+def _load_sample_notification_image() -> bytes | None:
+    """Render the bundled app screenshot as a small JPEG.
+
+    Stands in for a real camera snapshot on Test Configuration / test-all,
+    since there's no live print to capture one from. Cached after the first
+    call so we're not re-reading and resizing the file on every click.
+    """
+    global _sample_notification_image, _sample_notification_image_loaded
+    if _sample_notification_image_loaded:
+        return _sample_notification_image
+    _sample_notification_image_loaded = True
+    try:
+        from io import BytesIO
+
+        from PIL import Image
+
+        from backend.app.core.config import settings
+
+        source_path = settings.static_dir / "img" / "screenshot-desktop.png"
+        with Image.open(source_path) as img:
+            resized = img.convert("RGB")
+            resized.thumbnail((640, 640))
+            buffer = BytesIO()
+            resized.save(buffer, format="JPEG", quality=85)
+            _sample_notification_image = buffer.getvalue()
+    except Exception as e:
+        logger.warning("Failed to build sample notification image: %s", e)
+        _sample_notification_image = None
+    return _sample_notification_image
+
+
 class NotificationService:
 class NotificationService:
     """Service for sending notifications through various providers."""
     """Service for sending notifications through various providers."""
 
 
@@ -288,34 +324,51 @@ class NotificationService:
         return title, body
         return title, body
 
 
     async def send_test_notification(
     async def send_test_notification(
-        self, provider_type: str, config: dict[str, Any], db: AsyncSession | None = None
+        self,
+        provider_type: str,
+        config: dict[str, Any],
+        db: AsyncSession | None = None,
+        attach_photo: bool = True,
     ) -> tuple[bool, str]:
     ) -> tuple[bool, str]:
-        """Send a test notification to verify configuration."""
+        """Send a test notification to verify configuration.
+
+        attach_photo mirrors the provider's own toggle (see _send_to_provider)
+        — when set, a bundled sample image stands in for a real snapshot.
+        """
         if db:
         if db:
             title, message = await self._build_message_from_template(db, "test", {})
             title, message = await self._build_message_from_template(db, "test", {})
         else:
         else:
             title = "Bambuddy Test"
             title = "Bambuddy Test"
             message = "This is a test notification. If you see this, notifications are working!"
             message = "This is a test notification. If you see this, notifications are working!"
 
 
+        image_data = await asyncio.to_thread(_load_sample_notification_image) if attach_photo else None
+
         try:
         try:
             if provider_type == "callmebot":
             if provider_type == "callmebot":
                 return await self._send_callmebot(config, f"{title}\n{message}")
                 return await self._send_callmebot(config, f"{title}\n{message}")
             elif provider_type == "ntfy":
             elif provider_type == "ntfy":
-                return await self._send_ntfy(config, title, message)
+                return await self._send_ntfy(config, title, message, image_data=image_data)
             elif provider_type == "pushover":
             elif provider_type == "pushover":
-                return await self._send_pushover(config, title, message)
+                return await self._send_pushover(config, title, message, image_data=image_data)
             elif provider_type == "telegram":
             elif provider_type == "telegram":
-                return await self._send_telegram(config, f"*{title}*\n{message}")
+                return await self._send_telegram(config, f"*{title}*\n{message}", image_data=image_data)
             elif provider_type == "email":
             elif provider_type == "email":
-                return await self._send_email(config, title, message)
+                return await self._send_email(config, title, message, image_data=image_data)
             elif provider_type == "discord":
             elif provider_type == "discord":
-                return await self._send_discord(config, title, message)
+                return await self._send_discord(config, title, message, image_data=image_data)
             elif provider_type == "webhook":
             elif provider_type == "webhook":
-                return await self._send_webhook(config, title, message)
+                photo_url = None
+                if attach_photo and (config.get("payload_format") or "generic").strip() == "slack":
+                    photo_url = await self._get_or_build_photo_url(db, image_data, "test")
+                return await self._send_webhook(
+                    config, title, message, image_data=image_data, event_type="test", image_url=photo_url
+                )
             elif provider_type == "homeassistant":
             elif provider_type == "homeassistant":
-                return await self._send_homeassistant(config, title, message, db=db)
+                photo_url = await self._get_or_build_photo_url(db, image_data, "test") if attach_photo else None
+                return await self._send_homeassistant(config, title, message, db=db, image_url=photo_url)
             elif provider_type == "bark":
             elif provider_type == "bark":
-                return await self._send_bark(config, title, message)
+                photo_url = await self._get_or_build_photo_url(db, image_data, "test") if attach_photo else None
+                return await self._send_bark(config, title, message, image_url=photo_url)
             else:
             else:
                 return False, f"Unknown provider type: {provider_type}"
                 return False, f"Unknown provider type: {provider_type}"
         except Exception as e:
         except Exception as e:
@@ -342,7 +395,9 @@ class NotificationService:
         else:
         else:
             return False, f"HTTP {response.status_code}: {response.text[:200]}"
             return False, f"HTTP {response.status_code}: {response.text[:200]}"
 
 
-    async def _send_bark(self, config: dict, title: str, message: str, url: str | None = None) -> tuple[bool, str]:
+    async def _send_bark(
+        self, config: dict, title: str, message: str, url: str | None = None, image_url: str | None = None
+    ) -> tuple[bool, str]:
         """Send notification via Bark, the self-hostable iOS push service (#1495).
         """Send notification via Bark, the self-hostable iOS push service (#1495).
 
 
         POSTs JSON to {server}/push. Defaults to the official api.day.app
         POSTs JSON to {server}/push. Defaults to the official api.day.app
@@ -376,6 +431,10 @@ class NotificationService:
             payload["level"] = level
             payload["level"] = level
         if url:
         if url:
             payload["url"] = url
             payload["url"] = url
+        if image_url:
+            # Closest thing Bark has to a photo attachment — shows as a
+            # round icon on iOS.
+            payload["icon"] = image_url
 
 
         client = await self._get_client()
         client = await self._get_client()
         response = await client.post(f"{server}/push", json=payload)
         response = await client.post(f"{server}/push", json=payload)
@@ -926,6 +985,7 @@ class NotificationService:
         image_data: bytes | None = None,
         image_data: bytes | None = None,
         event_type: str | None = None,
         event_type: str | None = None,
         variables: dict | None = None,
         variables: dict | None = None,
+        image_url: str | None = None,
     ) -> tuple[bool, str]:
     ) -> tuple[bool, str]:
         """Send notification via generic webhook (POST JSON).
         """Send notification via generic webhook (POST JSON).
 
 
@@ -946,7 +1006,8 @@ class NotificationService:
 
 
         # Build payload based on format
         # Build payload based on format
         if payload_format == "slack":
         if payload_format == "slack":
-            # Slack/Mattermost format - just text field
+            # Incoming webhooks can't take a byte upload, so a photo has to
+            # be a URL Slack/Mattermost fetch themselves.
             data = {"text": f"*{title}*\n{message}"}
             data = {"text": f"*{title}*\n{message}"}
             if event_type == "print_confirm_request":
             if event_type == "print_confirm_request":
                 # Slack and Mattermost fetch every URL in the text to build
                 # Slack and Mattermost fetch every URL in the text to build
@@ -963,6 +1024,8 @@ class NotificationService:
                 # get it back.
                 # get it back.
                 data["unfurl_links"] = False
                 data["unfurl_links"] = False
                 data["unfurl_media"] = False
                 data["unfurl_media"] = False
+            if image_url:
+                data["attachments"] = [{"fallback": title, "image_url": image_url}]
         else:
         else:
             # Generic format with custom field names
             # Generic format with custom field names
             custom_field_title = config.get("field_title", "title").strip() or "title"
             custom_field_title = config.get("field_title", "title").strip() or "title"
@@ -1009,7 +1072,12 @@ class NotificationService:
             return False, f"Webhook error: {str(e)}"
             return False, f"Webhook error: {str(e)}"
 
 
     async def _send_homeassistant(
     async def _send_homeassistant(
-        self, config: dict, title: str, message: str, db: AsyncSession | None = None
+        self,
+        config: dict,
+        title: str,
+        message: str,
+        db: AsyncSession | None = None,
+        image_url: str | None = None,
     ) -> tuple[bool, str]:
     ) -> tuple[bool, str]:
         """Send notification via Home Assistant.
         """Send notification via Home Assistant.
 
 
@@ -1084,6 +1152,7 @@ class NotificationService:
         # reach the notify service. Only included when configured — the default
         # reach the notify service. Only included when configured — the default
         # persistent_notification.create schema rejects unknown keys.
         # persistent_notification.create schema rejects unknown keys.
         raw_data = config.get("data")
         raw_data = config.get("data")
+        data_payload: dict = {}
         if raw_data:
         if raw_data:
             if isinstance(raw_data, str):
             if isinstance(raw_data, str):
                 try:
                 try:
@@ -1094,8 +1163,19 @@ class NotificationService:
                 parsed_data = raw_data
                 parsed_data = raw_data
             if not isinstance(parsed_data, dict):
             if not isinstance(parsed_data, dict):
                 return False, 'The Data field must be a JSON object, e.g. {"priority": "high", "ttl": 0}'
                 return False, 'The Data field must be a JSON object, e.g. {"priority": "high", "ttl": 0}'
-            if parsed_data:
-                payload["data"] = parsed_data
+            data_payload = parsed_data
+
+        if image_url and service:
+            # HA's notify.* services fetch data.image themselves. Gated on a
+            # custom service being set: the default persistent_notification
+            # .create has a strict schema and 400s on fields it doesn't
+            # recognize, so we'd break plain notifications for anyone who
+            # never asked for a photo. setdefault so a user's own "image"
+            # key still wins.
+            data_payload.setdefault("image", image_url)
+
+        if data_payload:
+            payload["data"] = data_payload
 
 
         client = await self._get_client()
         client = await self._get_client()
         response = await client.post(url, json=payload, headers=headers)
         response = await client.post(url, json=payload, headers=headers)
@@ -1111,6 +1191,54 @@ class NotificationService:
             # gets the same treatment.
             # gets the same treatment.
             return False, _opaque_http_failure(response, label="Home Assistant endpoint")
             return False, _opaque_http_failure(response, label="Home Assistant endpoint")
 
 
+    async def _get_or_build_photo_url(
+        self,
+        db: AsyncSession | None,
+        image_data: bytes | None,
+        event_type: str | None,
+        photo_cache: dict | None = None,
+    ) -> str | None:
+        """Return a URL for a notification photo, for providers that fetch it themselves.
+
+        Writes *image_data* under an unguessable filename and builds the URL
+        from the ``external_url`` setting. The filename is the only credential
+        (see notification_photos.py), so the URL opens this one photo and
+        nothing else -- no camera token, which would open every printer's live
+        stream. Print Complete goes through here too rather than reusing the
+        archive's ``finish_photo_url``: that route needs a media token, so with
+        auth on HA/Bark/Slack would get a 401.
+
+        Returns None with no photo or no ``external_url`` set: HA and Bark
+        fetch this URL themselves, so a relative path does them no good.
+
+        *photo_cache* is shared by every provider in one send, so only the
+        first HA/Bark/Slack provider pays for the disk write. It's kept apart
+        from the template variables so the URL never lands in a generic
+        webhook's payload.
+        """
+        if photo_cache is not None and "url" in photo_cache:
+            return photo_cache["url"]
+
+        if not image_data or db is None:
+            return None
+
+        from backend.app.api.routes.settings import get_setting
+
+        external_url = (await get_setting(db, "external_url") or "").strip()
+        if not external_url:
+            return None
+
+        try:
+            filename = await asyncio.to_thread(save_notification_photo, image_data, event_type or "event")
+        except Exception as e:
+            logger.warning("Failed to persist notification photo: %s", e)
+            return None
+
+        url = f"{external_url.rstrip('/')}/api/v1/notifications/photos/{filename}"
+        if photo_cache is not None:
+            photo_cache["url"] = url
+        return url
+
     async def _send_to_provider(
     async def _send_to_provider(
         self,
         self,
         provider: NotificationProvider,
         provider: NotificationProvider,
@@ -1120,8 +1248,13 @@ class NotificationService:
         image_data: bytes | None = None,
         image_data: bytes | None = None,
         event_type: str | None = None,
         event_type: str | None = None,
         variables: dict | None = None,
         variables: dict | None = None,
+        photo_cache: dict | None = None,
     ) -> tuple[bool, str]:
     ) -> tuple[bool, str]:
-        """Send notification to a specific provider."""
+        """Send notification to a specific provider.
+
+        *photo_cache* lets the providers of one send share a single persisted
+        photo URL (see _get_or_build_photo_url).
+        """
         # Check quiet hours
         # Check quiet hours
         if self._is_in_quiet_hours(provider):
         if self._is_in_quiet_hours(provider):
             logger.info("Skipping notification to %s - quiet hours active", provider.name)
             logger.info("Skipping notification to %s - quiet hours active", provider.name)
@@ -1129,6 +1262,13 @@ class NotificationService:
 
 
         config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
         config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
 
 
+        # attach_photo is a per-provider opt-out. Clearing image_data covers
+        # the byte-upload providers below; HA/Bark/Slack get their own check
+        # further down too, since they could otherwise get the URL an earlier
+        # provider in the same send left in photo_cache.
+        if not provider.attach_photo:
+            image_data = None
+
         try:
         try:
             if provider.provider_type == "callmebot":
             if provider.provider_type == "callmebot":
                 return await self._send_callmebot(config, f"{title}\n{message}")
                 return await self._send_callmebot(config, f"{title}\n{message}")
@@ -1222,11 +1362,27 @@ class NotificationService:
             elif provider.provider_type == "discord":
             elif provider.provider_type == "discord":
                 return await self._send_discord(config, title, message, image_data=image_data)
                 return await self._send_discord(config, title, message, image_data=image_data)
             elif provider.provider_type == "webhook":
             elif provider.provider_type == "webhook":
+                # Only Slack format needs a URL — generic format gets the
+                # base64 image field instead.
+                photo_url = None
+                if provider.attach_photo and (config.get("payload_format") or "generic").strip() == "slack":
+                    photo_url = await self._get_or_build_photo_url(db, image_data, event_type, photo_cache)
                 return await self._send_webhook(
                 return await self._send_webhook(
-                    config, title, message, image_data=image_data, event_type=event_type, variables=variables
+                    config,
+                    title,
+                    message,
+                    image_data=image_data,
+                    event_type=event_type,
+                    variables=variables,
+                    image_url=photo_url,
                 )
                 )
             elif provider.provider_type == "homeassistant":
             elif provider.provider_type == "homeassistant":
-                return await self._send_homeassistant(config, title, message, db=db)
+                photo_url = (
+                    await self._get_or_build_photo_url(db, image_data, event_type, photo_cache)
+                    if provider.attach_photo
+                    else None
+                )
+                return await self._send_homeassistant(config, title, message, db=db, image_url=photo_url)
             elif provider.provider_type == "bark":
             elif provider.provider_type == "bark":
                 # Outcome confirmation (#1898): Bark opens one URL on tap —
                 # Outcome confirmation (#1898): Bark opens one URL on tap —
                 # deep-link into the confirmation dialog, like Pushover.
                 # deep-link into the confirmation dialog, like Pushover.
@@ -1234,7 +1390,12 @@ class NotificationService:
                 _bark_confirm = (variables or {}).get("confirm_url")
                 _bark_confirm = (variables or {}).get("confirm_url")
                 if event_type == "print_confirm_request" and _bark_confirm and _bark_confirm.startswith("http"):
                 if event_type == "print_confirm_request" and _bark_confirm and _bark_confirm.startswith("http"):
                     bark_url = _bark_confirm
                     bark_url = _bark_confirm
-                return await self._send_bark(config, title, message, url=bark_url)
+                photo_url = (
+                    await self._get_or_build_photo_url(db, image_data, event_type, photo_cache)
+                    if provider.attach_photo
+                    else None
+                )
+                return await self._send_bark(config, title, message, url=bark_url, image_url=photo_url)
             else:
             else:
                 return False, f"Unknown provider type: {provider.provider_type}"
                 return False, f"Unknown provider type: {provider.provider_type}"
         except Exception as e:
         except Exception as e:
@@ -1337,11 +1498,19 @@ class NotificationService:
         All notifications are always sent immediately. If digest mode is enabled,
         All notifications are always sent immediately. If digest mode is enabled,
         the notification is ALSO queued for the daily digest summary.
         the notification is ALSO queued for the daily digest summary.
         """
         """
+        photo_cache: dict = {}
         for provider in providers:
         for provider in providers:
             try:
             try:
                 # Always send notification immediately
                 # Always send notification immediately
                 success, error = await self._send_to_provider(
                 success, error = await self._send_to_provider(
-                    provider, title, message, db, image_data=image_data, event_type=event_type, variables=variables
+                    provider,
+                    title,
+                    message,
+                    db,
+                    image_data=image_data,
+                    event_type=event_type,
+                    variables=variables,
+                    photo_cache=photo_cache,
                 )
                 )
 
 
                 # Also queue for digest if enabled (digest is a summary, not a queue)
                 # Also queue for digest if enabled (digest is a summary, not a queue)
@@ -1864,6 +2033,7 @@ class NotificationService:
         printer_name: str,
         printer_name: str,
         db: AsyncSession,
         db: AsyncSession,
         difference_percent: float | None = None,
         difference_percent: float | None = None,
+        image_data: bytes | None = None,
     ):
     ):
         """Handle plate not empty event - objects detected on build plate before print."""
         """Handle plate not empty event - objects detected on build plate before print."""
         providers = await self._get_providers_for_event(db, "on_plate_not_empty", printer_id)
         providers = await self._get_providers_for_event(db, "on_plate_not_empty", printer_id)
@@ -1885,6 +2055,7 @@ class NotificationService:
             printer_id,
             printer_id,
             printer_name,
             printer_name,
             force_immediate=True,
             force_immediate=True,
+            image_data=image_data,
             variables=variables,
             variables=variables,
         )
         )
 
 
@@ -2309,6 +2480,8 @@ class NotificationService:
         printer_name: str,
         printer_name: str,
         filename: str,
         filename: str,
         db: AsyncSession,
         db: AsyncSession,
+        image_data: bytes | None = None,
+        finish_photo_url: str | None = None,
     ) -> None:
     ) -> None:
         """Send a print event email notification to the user who submitted the job.
         """Send a print event email notification to the user who submitted the job.
 
 
@@ -2318,6 +2491,10 @@ class NotificationService:
             printer_name: Name of the printer
             printer_name: Name of the printer
             filename: Raw filename or subtask name
             filename: Raw filename or subtask name
             db: Database session
             db: Database session
+            image_data: Camera snapshot bytes, if one was captured for the event.
+            finish_photo_url: The same snapshot's public URL — only used so the
+                template can reference {finish_photo_url}, which is what opts
+                the email into inlining the photo (see send_user_print_notification).
         """
         """
         if created_by_id is None:
         if created_by_id is None:
             logger.debug("[EMAIL] Skipping user print email (%s): no created_by_id", event_type)
             logger.debug("[EMAIL] Skipping user print email (%s): no created_by_id", event_type)
@@ -2403,6 +2580,8 @@ class NotificationService:
                 "printer": printer_name,
                 "printer": printer_name,
                 "filename": self._clean_filename(filename),
                 "filename": self._clean_filename(filename),
             }
             }
+            if finish_photo_url:
+                variables["finish_photo_url"] = finish_photo_url
 
 
             # Send the email
             # Send the email
             await send_user_print_notification(
             await send_user_print_notification(
@@ -2411,6 +2590,7 @@ class NotificationService:
                 user_email=user.email,
                 user_email=user.email,
                 username=user.username,
                 username=user.username,
                 variables=variables,
                 variables=variables,
+                image_data=image_data,
             )
             )
             logger.info("[EMAIL] User print email sent: event=%s → %s", event_type, user.email)
             logger.info("[EMAIL] User print email sent: event=%s → %s", event_type, user.email)
         except Exception as e:
         except Exception as e:

+ 10 - 3
backend/app/services/obico_actions.py

@@ -13,10 +13,14 @@ from backend.app.models.printer import Printer
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
-async def execute_action(printer_id: int, action: str, task_name: str, score: float) -> None:
+async def execute_action(
+    printer_id: int, action: str, task_name: str, score: float, frame: bytes | None = None
+) -> None:
     """Run the configured action for a detected print failure.
     """Run the configured action for a detected print failure.
 
 
     action: 'notify' | 'pause' | 'pause_and_off'
     action: 'notify' | 'pause' | 'pause_and_off'
+    frame: the camera snapshot the ML model flagged, if any — attached to
+        the notification so the recipient can see what triggered it.
     """
     """
     printer_name = await _get_printer_name(printer_id)
     printer_name = await _get_printer_name(printer_id)
 
 
@@ -26,7 +30,7 @@ async def execute_action(printer_id: int, action: str, task_name: str, score: fl
     if action == "pause_and_off":
     if action == "pause_and_off":
         await _turn_off_linked_plugs(printer_id)
         await _turn_off_linked_plugs(printer_id)
 
 
-    await _notify(printer_id, printer_name, task_name, score, action)
+    await _notify(printer_id, printer_name, task_name, score, action, frame)
 
 
 
 
 async def _get_printer_name(printer_id: int) -> str:
 async def _get_printer_name(printer_id: int) -> str:
@@ -63,7 +67,9 @@ async def _turn_off_linked_plugs(printer_id: int) -> None:
                 logger.error("Obico action: failed to turn off plug %s: %s", plug.name, e)
                 logger.error("Obico action: failed to turn off plug %s: %s", plug.name, e)
 
 
 
 
-async def _notify(printer_id: int, printer_name: str, task_name: str, score: float, action: str) -> None:
+async def _notify(
+    printer_id: int, printer_name: str, task_name: str, score: float, action: str, frame: bytes | None = None
+) -> None:
     """Fire the AI Failure Detection notification (#1794).
     """Fire the AI Failure Detection notification (#1794).
 
 
     Routed to its own event in 0.2.5b1; previously rode the multiplexed
     Routed to its own event in 0.2.5b1; previously rode the multiplexed
@@ -81,6 +87,7 @@ async def _notify(printer_id: int, printer_name: str, task_name: str, score: flo
                 confidence=score,
                 confidence=score,
                 action=action,
                 action=action,
                 db=db,
                 db=db,
+                image_data=frame,
             )
             )
         except Exception as e:
         except Exception as e:
             logger.error("Obico notify failed for printer %s: %s", printer_id, e)
             logger.error("Obico notify failed for printer %s: %s", printer_id, e)

+ 7 - 3
backend/app/services/obico_detection.py

@@ -369,9 +369,11 @@ class ObicoDetectionService:
 
 
         if verdict == "failure" and not self._action_fired.get(printer_id):
         if verdict == "failure" and not self._action_fired.get(printer_id):
             self._action_fired[printer_id] = True
             self._action_fired[printer_id] = True
-            await self._dispatch_action(printer_id, settings["action"], task_name, score)
+            await self._dispatch_action(printer_id, settings["action"], task_name, score, frame)
 
 
-    async def _dispatch_action(self, printer_id: int, action: str, task_name: str, score: float):
+    async def _dispatch_action(
+        self, printer_id: int, action: str, task_name: str, score: float, frame: bytes | None = None
+    ):
         from backend.app.services.obico_actions import execute_action
         from backend.app.services.obico_actions import execute_action
 
 
         logger.warning(
         logger.warning(
@@ -382,7 +384,9 @@ class ObicoDetectionService:
             action,
             action,
         )
         )
         try:
         try:
-            await execute_action(printer_id, action, task_name, score)
+            # Same frame the ML model flagged, not a fresh capture — the
+            # printer's moved on by the time this fires.
+            await execute_action(printer_id, action, task_name, score, frame)
         except Exception as e:
         except Exception as e:
             self._last_error = f"Action dispatch failed: {e or type(e).__name__}"
             self._last_error = f"Action dispatch failed: {e or type(e).__name__}"
             logger.error(self._last_error)
             logger.error(self._last_error)

+ 104 - 0
backend/app/utils/notification_photos.py

@@ -0,0 +1,104 @@
+"""Where ad-hoc notification snapshots live on disk.
+
+Most events (first layer complete, plate not empty, printer errors, ...) hand
+their captured camera frame straight to providers as raw bytes. Home Assistant
+and Bark need an HTTP URL instead, since they fetch it themselves, so those
+bytes have to land somewhere servable first.
+
+These aren't tied to a PrintArchive (plate-not-empty runs before one exists)
+and shouldn't show up in an archive's photo gallery, so they get their own
+flat directory instead of reusing archive_paths.py. Nothing links to them
+beyond the notification that triggered the capture, so they're just pruned by
+age on write rather than tracked in the database.
+
+The filename is the credential. HA, Bark and Slack fetch the URL with no
+session, and it ends up in chat channels and on Bark's relay, so it must not
+carry anything that opens more than this one photo -- a camera stream token
+would open every printer's live stream for an hour. Instead each name embeds
+``secrets.token_urlsafe(24)`` (192 bits), the route serves only names of that
+exact shape, and a photo older than ``_MAX_AGE_SECONDS`` is refused even if a
+prune hasn't removed it yet, so the age limit is the URL's expiry.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+import secrets
+import time
+from datetime import datetime
+from pathlib import Path
+
+from backend.app.core.config import settings
+from backend.app.utils.safe_path import PathTraversalError, safe_join_under
+
+logger = logging.getLogger(__name__)
+
+# These only need to survive long enough for a provider to fetch them once
+# after the notification goes out, so a few days of slack is plenty.
+_MAX_AGE_SECONDS = 3 * 24 * 60 * 60  # 3 days
+
+# {event}_{YYYYmmdd}_{HHMMSS}_{token_urlsafe(24)}.jpg -- 24 random bytes encode
+# to exactly 32 URL-safe base64 characters.
+_TOKEN_BYTES = 24
+_FILENAME_RE = re.compile(r"[a-z0-9_]+_\d{8}_\d{6}_[A-Za-z0-9_-]{32}\.jpg")
+
+
+def notification_photos_dir() -> Path:
+    return settings.base_dir / "notification_photos"  # SEC-PATH-OK: constant subdirectory
+
+
+def _prune_old_photos(directory: Path) -> None:
+    """Best-effort deletion of files older than ``_MAX_AGE_SECONDS``.
+
+    Failures here must never block a notification from sending, so every
+    error is swallowed after a debug log.
+    """
+    try:
+        cutoff = time.time() - _MAX_AGE_SECONDS
+        for entry in directory.iterdir():
+            try:
+                if entry.is_file() and entry.stat().st_mtime < cutoff:
+                    entry.unlink()
+            except OSError:
+                continue
+    except OSError as e:
+        logger.debug("Failed to prune notification photos: %s", e)
+
+
+def save_notification_photo(image_data: bytes, event_type: str) -> str:
+    """Write *image_data* to the notification photos dir and return its filename.
+
+    Runs synchronously — callers on the async path should wrap this in
+    ``asyncio.to_thread``.
+    """
+    directory = notification_photos_dir()
+    directory.mkdir(parents=True, exist_ok=True)
+    _prune_old_photos(directory)
+
+    safe_event = re.sub(r"[^a-z0-9_]", "", event_type.lower()) or "event"
+    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+    filename = f"{safe_event}_{timestamp}_{secrets.token_urlsafe(_TOKEN_BYTES)}.jpg"
+    path = directory / filename  # SEC-PATH-OK: filename generated above, not user input
+    path.write_bytes(image_data)
+    return filename
+
+
+def find_notification_photo(filename: str) -> Path | None:
+    """Resolve *filename* under the notification photos dir.
+
+    None unless it has the exact shape ``save_notification_photo`` produces,
+    exists, and is younger than ``_MAX_AGE_SECONDS``.
+    """
+    if not _FILENAME_RE.fullmatch(filename):
+        return None
+    try:
+        candidate = safe_join_under(notification_photos_dir(), filename, http=False)
+    except PathTraversalError:
+        return None
+    try:
+        if not candidate.is_file() or candidate.stat().st_mtime < time.time() - _MAX_AGE_SECONDS:
+            return None
+    except OSError:
+        return None
+    return candidate

+ 2 - 0
backend/tests/conftest.py

@@ -494,6 +494,8 @@ def mock_httpx_client():
 
 
         mock_instance.get = AsyncMock(return_value=mock_response)
         mock_instance.get = AsyncMock(return_value=mock_response)
         mock_instance.post = AsyncMock(return_value=mock_response)
         mock_instance.post = AsyncMock(return_value=mock_response)
+        # ntfy switches to PUT when a photo is attached (e.g. the sample image on Test).
+        mock_instance.put = AsyncMock(return_value=mock_response)
         mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
         mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
         mock_instance.__aexit__ = AsyncMock()
         mock_instance.__aexit__ = AsyncMock()
 
 

+ 467 - 0
backend/tests/unit/services/test_notification_photo_attachments.py

@@ -0,0 +1,467 @@
+"""Unit tests for camera snapshot attachments on notifications.
+
+Covers the per-provider attach_photo opt-out, the fetched-URL providers (Home
+Assistant, Bark, Slack-format webhooks) and the on-disk store they fetch from,
+and the inline image on emails.
+"""
+
+import json
+import os
+import time
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.schemas.notification_template import EVENT_VARIABLES, PHOTO_CAPABLE_EVENTS
+from backend.app.services.email_service import send_email
+from backend.app.services.notification_service import NotificationService
+from backend.app.utils import notification_photos
+
+
+def _client_returning(status_code=200, json_body=None):
+    mock_response = MagicMock()
+    mock_response.status_code = status_code
+    mock_response.text = ""
+    mock_response.json = MagicMock(return_value=json_body or {})
+    mock_client = AsyncMock()
+    mock_client.post = AsyncMock(return_value=mock_response)
+    return mock_client
+
+
+def _provider(provider_type: str, config: dict, attach_photo: bool = True):
+    provider = MagicMock()
+    provider.provider_type = provider_type
+    provider.config = json.dumps(config)
+    provider.quiet_hours_enabled = False
+    provider.attach_photo = attach_photo
+    return provider
+
+
+@pytest.fixture
+def service():
+    return NotificationService()
+
+
+class TestNotificationPhotoStore:
+    """The flat directory HA/Bark/Slack fetch ad-hoc snapshots from."""
+
+    @pytest.fixture(autouse=True)
+    def _base_dir(self, tmp_path, monkeypatch):
+        monkeypatch.setattr(notification_photos.settings, "base_dir", tmp_path)
+        self.directory = tmp_path / "notification_photos"
+
+    def test_save_then_find_round_trips(self):
+        filename = notification_photos.save_notification_photo(b"jpeg-bytes", "plate_not_empty")
+
+        assert filename.startswith("plate_not_empty_")
+        assert filename.endswith(".jpg")
+        found = notification_photos.find_notification_photo(filename)
+        assert found is not None
+        assert found.read_bytes() == b"jpeg-bytes"
+
+    def test_event_type_is_sanitised_into_the_filename(self):
+        filename = notification_photos.save_notification_photo(b"x", "../../etc/passwd")
+
+        assert "/" not in filename
+        assert ".." not in filename
+        assert (self.directory / filename).exists()
+
+    def test_filename_embeds_a_urlsafe_token(self):
+        """The filename is the URL's only credential, so it has to carry the
+        full 24-byte token, not a guessable suffix."""
+        with patch.object(notification_photos.secrets, "token_urlsafe", return_value="T" * 32) as mock_token:
+            filename = notification_photos.save_notification_photo(b"x", "test")
+
+        mock_token.assert_called_once_with(24)
+        assert filename.endswith("_" + "T" * 32 + ".jpg")
+
+    def test_filenames_are_unique_per_save(self):
+        first = notification_photos.save_notification_photo(b"x", "test")
+        second = notification_photos.save_notification_photo(b"x", "test")
+
+        assert first != second
+
+    def test_find_rejects_traversal_and_missing_files(self):
+        notification_photos.save_notification_photo(b"x", "test")
+
+        assert notification_photos.find_notification_photo("../secret.jpg") is None
+        assert notification_photos.find_notification_photo("does_not_exist.jpg") is None
+
+    @pytest.mark.parametrize(
+        "name",
+        [
+            # the pre-#3089-review shape: an 8-hex uuid suffix is guessable
+            "test_20260930_120000_abcdef12.jpg",
+            # token one character short / long
+            "test_20260930_120000_" + "A" * 31 + ".jpg",
+            "test_20260930_120000_" + "A" * 33 + ".jpg",
+            # other extensions, even if such a file exists on disk
+            "test_20260930_120000_" + "A" * 32 + ".png",
+            "test_20260930_120000_" + "A" * 32 + ".jpg.bak",
+        ],
+    )
+    def test_find_serves_only_the_exact_saved_shape(self, name):
+        self.directory.mkdir(parents=True, exist_ok=True)
+        (self.directory / name).write_bytes(b"x")
+
+        assert notification_photos.find_notification_photo(name) is None
+
+    def test_find_refuses_a_photo_past_its_expiry_even_before_prune(self):
+        """The 3-day age limit is the URL's expiry, so it can't depend on
+        another notification coming along to trigger a prune."""
+        filename = notification_photos.save_notification_photo(b"x", "test")
+        old = time.time() - notification_photos._MAX_AGE_SECONDS - 60
+        os.utime(self.directory / filename, (old, old))
+
+        assert notification_photos.find_notification_photo(filename) is None
+
+    def test_save_prunes_photos_older_than_max_age(self):
+        self.directory.mkdir(parents=True)
+        stale = self.directory / "stale.jpg"
+        stale.write_bytes(b"old")
+        old = time.time() - notification_photos._MAX_AGE_SECONDS - 60
+        os.utime(stale, (old, old))
+        fresh = self.directory / "fresh.jpg"
+        fresh.write_bytes(b"new")
+
+        notification_photos.save_notification_photo(b"x", "test")
+
+        assert not stale.exists()
+        assert fresh.exists()
+
+
+class TestPhotoCapableEvents:
+    def test_every_photo_capable_event_is_a_known_event(self):
+        """supports_photo is looked up per EVENT_VARIABLES key, so a typo here
+        would silently never show the preview."""
+        assert set(EVENT_VARIABLES) >= PHOTO_CAPABLE_EVENTS
+
+
+class TestAttachPhotoOptOut:
+    """provider.attach_photo=False must keep the photo off every provider type."""
+
+    @pytest.mark.asyncio
+    async def test_byte_upload_provider_gets_no_image_when_opted_out(self, service):
+        provider = _provider("ntfy", {"topic": "t"}, attach_photo=False)
+
+        with patch.object(service, "_send_ntfy", new_callable=AsyncMock) as mock_send:
+            mock_send.return_value = (True, "OK")
+            await service._send_to_provider(provider, "Title", "Body", db=AsyncMock(), image_data=b"jpeg")
+
+        assert mock_send.call_args.kwargs.get("image_data") is None
+
+    @pytest.mark.asyncio
+    async def test_byte_upload_provider_gets_image_by_default(self, service):
+        provider = _provider("ntfy", {"topic": "t"})
+
+        with patch.object(service, "_send_ntfy", new_callable=AsyncMock) as mock_send:
+            mock_send.return_value = (True, "OK")
+            await service._send_to_provider(provider, "Title", "Body", db=AsyncMock(), image_data=b"jpeg")
+
+        assert mock_send.call_args.kwargs.get("image_data") == b"jpeg"
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        ("provider_type", "config", "sender"),
+        [
+            ("bark", {"device_key": "abc"}, "_send_bark"),
+            ("homeassistant", {"service": "notify.mobile_app_x"}, "_send_homeassistant"),
+            ("webhook", {"webhook_url": "http://hook.local", "payload_format": "slack"}, "_send_webhook"),
+        ],
+    )
+    async def test_fetched_url_providers_skip_the_url_when_opted_out(self, service, provider_type, config, sender):
+        """HA/Bark/Slack could otherwise get the URL an earlier provider in the
+        same send left in the shared photo cache, so they need their own check."""
+        provider = _provider(provider_type, config, attach_photo=False)
+        photo_cache = {"url": "https://bambuddy.example/api/v1/notifications/photos/a.jpg"}
+
+        with (
+            patch.object(service, sender, new_callable=AsyncMock) as mock_send,
+            patch.object(service, "_get_or_build_photo_url", wraps=service._get_or_build_photo_url) as mock_build,
+        ):
+            mock_send.return_value = (True, "OK")
+            await service._send_to_provider(
+                provider, "Title", "Body", db=AsyncMock(), image_data=b"jpeg", photo_cache=photo_cache
+            )
+
+        mock_build.assert_not_called()
+        assert mock_send.call_args.kwargs.get("image_url") is None
+
+    @pytest.mark.asyncio
+    async def test_one_send_persists_the_photo_once_and_keeps_it_out_of_variables(self, service):
+        """Every HA/Bark/Slack provider in a send shares one saved photo, and
+        the URL (the photo's only credential) never reaches the variables a
+        generic webhook copies into its payload."""
+        providers = [
+            _provider("bark", {"device_key": "abc"}),
+            _provider("homeassistant", {"service": "notify.mobile_app_x"}),
+        ]
+        for i, provider in enumerate(providers):
+            provider.id = i
+            provider.name = f"p{i}"
+            provider.daily_digest_enabled = False
+        variables = {"printer": "X1C"}
+
+        with (
+            patch.object(service, "_send_bark", new_callable=AsyncMock, return_value=(True, "OK")) as bark,
+            patch.object(service, "_send_homeassistant", new_callable=AsyncMock, return_value=(True, "OK")) as ha,
+            patch.object(service, "_update_provider_status", new_callable=AsyncMock),
+            patch.object(service, "_log_notification", new_callable=AsyncMock),
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value="https://bambuddy.example",
+            ),
+            patch(
+                "backend.app.services.notification_service.save_notification_photo", return_value="p.jpg"
+            ) as mock_save,
+        ):
+            await service._send_to_providers(
+                providers, "T", "B", AsyncMock(), event_type="print_complete", image_data=b"jpeg", variables=variables
+            )
+
+        mock_save.assert_called_once()
+        url = "https://bambuddy.example/api/v1/notifications/photos/p.jpg"
+        assert bark.call_args.kwargs["image_url"] == url
+        assert ha.call_args.kwargs["image_url"] == url
+        assert variables == {"printer": "X1C"}
+
+    @pytest.mark.asyncio
+    async def test_bark_gets_photo_url_when_enabled(self, service):
+        provider = _provider("bark", {"device_key": "abc"})
+
+        with (
+            patch.object(service, "_send_bark", new_callable=AsyncMock) as mock_send,
+            patch.object(service, "_get_or_build_photo_url", new_callable=AsyncMock) as mock_build,
+        ):
+            mock_send.return_value = (True, "OK")
+            mock_build.return_value = "https://bambuddy.example/photo.jpg"
+            await service._send_to_provider(provider, "Title", "Body", db=AsyncMock(), image_data=b"jpeg")
+
+        assert mock_send.call_args.kwargs.get("image_url") == "https://bambuddy.example/photo.jpg"
+
+    @pytest.mark.asyncio
+    async def test_generic_webhook_never_builds_a_photo_url(self, service):
+        """The generic format carries base64 bytes, so it must not pay for a disk write."""
+        provider = _provider("webhook", {"webhook_url": "http://hook.local"})
+
+        with (
+            patch.object(service, "_send_webhook", new_callable=AsyncMock) as mock_send,
+            patch.object(service, "_get_or_build_photo_url", new_callable=AsyncMock) as mock_build,
+        ):
+            mock_send.return_value = (True, "OK")
+            await service._send_to_provider(provider, "Title", "Body", db=AsyncMock(), image_data=b"jpeg")
+
+        mock_build.assert_not_called()
+        assert mock_send.call_args.kwargs.get("image_data") == b"jpeg"
+
+
+class TestFetchedUrlPayloads:
+    @pytest.mark.asyncio
+    async def test_bark_sends_photo_as_icon(self, service):
+        mock_client = _client_returning(200, {"code": 200})
+
+        with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
+            await service._send_bark({"device_key": "abc"}, "T", "B", image_url="https://x.example/p.jpg")
+
+        assert mock_client.post.call_args.kwargs["json"]["icon"] == "https://x.example/p.jpg"
+
+    @pytest.mark.asyncio
+    async def test_bark_keeps_tap_url_alongside_photo(self, service):
+        mock_client = _client_returning(200, {"code": 200})
+
+        with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
+            await service._send_bark(
+                {"device_key": "abc"}, "T", "B", url="https://x.example/confirm", image_url="https://x.example/p.jpg"
+            )
+
+        payload = mock_client.post.call_args.kwargs["json"]
+        assert payload["url"] == "https://x.example/confirm"
+        assert payload["icon"] == "https://x.example/p.jpg"
+
+    @pytest.mark.asyncio
+    async def test_slack_webhook_attaches_photo_url(self, service):
+        mock_client = _client_returning()
+        config = {"webhook_url": "http://mattermost.local/hooks/abc", "payload_format": "slack"}
+
+        with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
+            await service._send_webhook(config, "T", "B", image_data=b"jpeg", image_url="https://x.example/p.jpg")
+
+        payload = mock_client.post.call_args.kwargs["json"]
+        assert payload["attachments"] == [{"fallback": "T", "image_url": "https://x.example/p.jpg"}]
+        assert "image" not in payload
+
+    @pytest.fixture
+    def ha_settings(self):
+        with patch(
+            "backend.app.api.routes.settings.get_homeassistant_settings",
+            new_callable=AsyncMock,
+            return_value={"ha_url": "http://ha.local:8123", "ha_token": "tok", "ha_enabled": True},
+        ):
+            yield
+
+    @pytest.mark.asyncio
+    async def test_homeassistant_notify_service_gets_data_image(self, service, ha_settings):
+        mock_client = _client_returning()
+
+        with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
+            await service._send_homeassistant(
+                {"service": "notify.mobile_app_x"}, "T", "B", db=AsyncMock(), image_url="https://x.example/p.jpg"
+            )
+
+        assert mock_client.post.call_args.kwargs["json"]["data"] == {"image": "https://x.example/p.jpg"}
+
+    @pytest.mark.asyncio
+    async def test_homeassistant_persistent_notification_never_gets_image(self, service, ha_settings):
+        """persistent_notification.create 400s on unknown keys, so the default
+        service must not get a data block just because a photo exists."""
+        mock_client = _client_returning()
+
+        with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
+            await service._send_homeassistant({}, "T", "B", db=AsyncMock(), image_url="https://x.example/p.jpg")
+
+        assert "data" not in mock_client.post.call_args.kwargs["json"]
+
+    @pytest.mark.asyncio
+    async def test_homeassistant_user_data_image_wins(self, service, ha_settings):
+        mock_client = _client_returning()
+        config = {"service": "notify.mobile_app_x", "data": json.dumps({"image": "mine", "ttl": 0})}
+
+        with patch.object(service, "_get_client", new_callable=AsyncMock, return_value=mock_client):
+            await service._send_homeassistant(config, "T", "B", db=AsyncMock(), image_url="https://x.example/p.jpg")
+
+        assert mock_client.post.call_args.kwargs["json"]["data"] == {"image": "mine", "ttl": 0}
+
+
+class TestGetOrBuildPhotoUrl:
+    @pytest.fixture
+    def external_url(self):
+        with patch(
+            "backend.app.api.routes.settings.get_setting",
+            new_callable=AsyncMock,
+            return_value="https://bambuddy.example/",
+        ):
+            yield
+
+    @pytest.mark.asyncio
+    async def test_no_image_returns_none(self, service):
+        assert await service._get_or_build_photo_url(AsyncMock(), None, "x") is None
+
+    @pytest.mark.asyncio
+    async def test_no_external_url_returns_none(self, service):
+        """A relative path is useless to a service that fetches it itself."""
+        with (
+            patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=""),
+            patch("backend.app.services.notification_service.save_notification_photo") as mock_save,
+        ):
+            result = await service._get_or_build_photo_url(AsyncMock(), b"jpeg", "x")
+
+        assert result is None
+        mock_save.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_saves_and_builds_a_url_with_no_token(self, service, external_url):
+        """The filename is the credential. A camera stream token here would
+        open every printer's live stream to whoever sees the notification."""
+        with (
+            patch(
+                "backend.app.services.notification_service.save_notification_photo", return_value="first_layer_a.jpg"
+            ) as mock_save,
+            patch("backend.app.core.auth.create_camera_stream_token", new_callable=AsyncMock) as mock_token,
+        ):
+            result = await service._get_or_build_photo_url(AsyncMock(), b"jpeg", "first_layer_complete")
+
+        assert result == "https://bambuddy.example/api/v1/notifications/photos/first_layer_a.jpg"
+        mock_save.assert_called_once_with(b"jpeg", "first_layer_complete")
+        mock_token.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_print_complete_gets_its_own_photo_not_the_archive_url(self, service, external_url):
+        """The archive's finish_photo_url needs a media token, so with auth on
+        HA/Bark/Slack would get a 401 on it. Print Complete must build its own."""
+        with patch("backend.app.services.notification_service.save_notification_photo", return_value="p.jpg"):
+            result = await service._get_or_build_photo_url(AsyncMock(), b"jpeg", "print_complete")
+
+        assert result == "https://bambuddy.example/api/v1/notifications/photos/p.jpg"
+
+    @pytest.mark.asyncio
+    async def test_cache_is_filled_and_then_reused(self, service, external_url):
+        photo_cache: dict = {}
+
+        with patch(
+            "backend.app.services.notification_service.save_notification_photo", return_value="p.jpg"
+        ) as mock_save:
+            first = await service._get_or_build_photo_url(AsyncMock(), b"jpeg", "x", photo_cache)
+            second = await service._get_or_build_photo_url(AsyncMock(), b"jpeg", "x", photo_cache)
+
+        assert first == second == photo_cache["url"]
+        mock_save.assert_called_once()
+
+
+class TestNotificationPhotoRoute:
+    """GET /notifications/photos/{filename} is public: the filename is the auth."""
+
+    @pytest.mark.asyncio
+    async def test_serves_a_saved_photo_without_credentials(self, tmp_path, monkeypatch):
+        from backend.app.api.routes.notifications import get_notification_photo
+
+        monkeypatch.setattr(notification_photos.settings, "base_dir", tmp_path)
+        filename = notification_photos.save_notification_photo(b"\xff\xd8jpeg", "test")
+
+        response = await get_notification_photo(filename)
+
+        assert response.media_type == "image/jpeg"
+        assert response.headers["cache-control"] == "private, no-store"
+
+    @pytest.mark.asyncio
+    async def test_unknown_name_is_404(self, tmp_path, monkeypatch):
+        from fastapi import HTTPException
+
+        from backend.app.api.routes.notifications import get_notification_photo
+
+        monkeypatch.setattr(notification_photos.settings, "base_dir", tmp_path)
+
+        with pytest.raises(HTTPException) as exc:
+            await get_notification_photo("test_20260930_120000_" + "A" * 32 + ".jpg")
+
+        assert exc.value.status_code == 404
+
+
+class TestSendEmailInlineImage:
+    @pytest.fixture
+    def smtp_settings(self):
+        return SimpleNamespace(
+            smtp_from_name="Bambuddy",
+            smtp_from_email="bambuddy@example.com",
+            smtp_security="none",
+            smtp_auth_enabled=False,
+            smtp_username=None,
+            smtp_password=None,
+            smtp_host="smtp.example.com",
+            smtp_port=25,
+        )
+
+    def _sent_message(self, smtp_settings, **kwargs):
+        with patch("backend.app.services.email_service.smtplib.SMTP") as mock_smtp:
+            send_email(smtp_settings, "to@example.com", "Subject", "text", **kwargs)
+        return mock_smtp.return_value.__enter__.return_value.send_message.call_args[0][0]
+
+    def test_image_with_html_becomes_multipart_related(self, smtp_settings):
+        msg = self._sent_message(
+            smtp_settings, body_html='<img src="cid:photo">', image_data=b"\xff\xd8jpeg", image_cid="photo"
+        )
+
+        assert msg.get_content_type() == "multipart/related"
+        alternative, image = msg.get_payload()
+        assert alternative.get_content_type() == "multipart/alternative"
+        assert image.get_content_type() == "image/jpeg"
+        assert image["Content-ID"] == "<photo>"
+        assert image.get_payload(decode=True) == b"\xff\xd8jpeg"
+
+    def test_image_without_html_is_dropped(self, smtp_settings):
+        """No HTML part means nowhere to reference the cid, so no attachment."""
+        msg = self._sent_message(smtp_settings, image_data=b"jpeg")
+
+        assert msg.get_content_type() == "multipart/alternative"
+        assert [part.get_content_type() for part in msg.get_payload()] == ["text/plain"]

+ 148 - 0
backend/tests/unit/test_plate_check_chamber_light.py

@@ -0,0 +1,148 @@
+"""Plate check: the chamber light it switched on is always switched back off.
+
+on_print_start turns the chamber light on for plate detection when it was
+off. It must go back off as soon as the camera is done with it -- before the
+broadcast and the provider sends -- and also when anything on the way raises,
+or the printer is left lit for the rest of the print.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.main import (
+    _active_prints,
+    _expected_print_creators,
+    _expected_print_registered_at,
+    _expected_prints,
+    _print_ams_mappings,
+    _timelapse_baselines,
+)
+
+
+@pytest.fixture(autouse=True)
+def _clear_dicts():
+    dicts = (
+        _expected_prints,
+        _expected_print_registered_at,
+        _expected_print_creators,
+        _print_ams_mappings,
+        _active_prints,
+        _timelapse_baselines,
+    )
+    for d in dicts:
+        d.clear()
+    yield
+    for d in dicts:
+        d.clear()
+
+
+def _printer():
+    printer = MagicMock()
+    printer.id = 1
+    printer.name = "TestP1S"
+    printer.plate_detection_enabled = True
+    printer.plate_detection_roi_x = None
+    printer.plate_detection_roi_y = None
+    printer.plate_detection_roi_w = None
+    printer.plate_detection_roi_h = None
+    printer.auto_archive = False
+    printer.external_camera_enabled = False
+    printer.external_camera_url = None
+    return printer
+
+
+async def _run_print_start(events: list[str], *, check_plate_empty, pause_side_effect=None):
+    printer = _printer()
+
+    client = MagicMock()
+    client.state = SimpleNamespace(chamber_light=False)
+    client.set_chamber_light = MagicMock(side_effect=lambda on: events.append(f"light:{'on' if on else 'off'}"))
+    client.pause_print = MagicMock(side_effect=pause_side_effect or (lambda: events.append("pause")))
+
+    def execute_router(stmt, *args, **kwargs):
+        sql = str(stmt).lower()
+        found = printer if "from printers" in sql else None
+        return MagicMock(
+            scalar_one_or_none=MagicMock(return_value=found),
+            scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[found] if found else []))),
+        )
+
+    session = AsyncMock()
+    session.__aenter__ = AsyncMock(return_value=session)
+    session.__aexit__ = AsyncMock()
+    session.execute = AsyncMock(side_effect=execute_router)
+
+    async def broadcast(_message):
+        events.append("broadcast")
+
+    async def notify(**_kwargs):
+        events.append("notify")
+
+    with (
+        patch("backend.app.main.async_session", return_value=session),
+        patch("backend.app.main.notification_service") as mock_notif,
+        patch("backend.app.main.smart_plug_manager") as mock_plug,
+        patch("backend.app.main.ws_manager") as mock_ws,
+        patch("backend.app.main.printer_manager") as mock_pm,
+        patch("backend.app.main.mqtt_relay") as mock_relay,
+        patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
+        patch("backend.app.main._capture_snapshot_for_notification", new_callable=AsyncMock, return_value=b"jpeg"),
+        patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
+        patch("backend.app.main._load_objects_from_archive"),
+        patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
+        patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
+        patch("backend.app.services.plate_detection.check_plate_empty", check_plate_empty),
+    ):
+        mock_notif.on_plate_not_empty = AsyncMock(side_effect=notify)
+        mock_notif.on_print_start = AsyncMock()
+        mock_plug.on_print_start = AsyncMock()
+        mock_ws.broadcast = AsyncMock(side_effect=broadcast)
+        mock_ws.send_print_start = AsyncMock()
+        mock_ws.send_archive_updated = AsyncMock()
+        mock_relay.on_print_start = AsyncMock()
+        mock_pm.get_client = MagicMock(return_value=client)
+        mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
+
+        from backend.app.main import on_print_start
+
+        await on_print_start(1, {"filename": "Benchy.3mf", "subtask_name": "Benchy"})
+
+
+def _objects_on_plate():
+    return AsyncMock(
+        return_value=SimpleNamespace(needs_calibration=False, is_empty=False, confidence=0.9, difference_percent=12.0)
+    )
+
+
+@pytest.mark.asyncio
+async def test_light_goes_off_before_the_notifications_are_sent():
+    events: list[str] = []
+
+    await _run_print_start(events, check_plate_empty=_objects_on_plate())
+
+    assert events.index("light:on") < events.index("light:off")
+    assert events.index("light:off") < events.index("broadcast")
+    assert events.index("light:off") < events.index("notify")
+
+
+@pytest.mark.asyncio
+async def test_light_goes_off_when_detection_raises():
+    events: list[str] = []
+
+    await _run_print_start(events, check_plate_empty=AsyncMock(side_effect=RuntimeError("camera gone")))
+
+    assert events == ["light:on", "light:off"]
+
+
+@pytest.mark.asyncio
+async def test_light_goes_off_when_pausing_raises():
+    events: list[str] = []
+
+    await _run_print_start(
+        events, check_plate_empty=_objects_on_plate(), pause_side_effect=RuntimeError("mqtt disconnected")
+    )
+
+    assert "light:off" in events
+    assert "notify" not in events

+ 5 - 0
backend/tests/unit/test_route_auth_coverage.py

@@ -97,6 +97,11 @@ _PUBLIC_ROUTES: frozenset[tuple[str, str]] = frozenset(
         # confirmation page and writes nothing; the POST behind it records.
         # confirmation page and writes nothing; the POST behind it records.
         ("GET", "/api/v1/archives/confirm/{token}/{verdict}"),
         ("GET", "/api/v1/archives/confirm/{token}/{verdict}"),
         ("POST", "/api/v1/archives/confirm/{token}/{verdict}"),
         ("POST", "/api/v1/archives/confirm/{token}/{verdict}"),
+        # Notification snapshot (#3089) — HA, Bark and Slack fetch it with no
+        # session. The filename embeds secrets.token_urlsafe(24) and is the
+        # credential; the handler serves only names of that exact shape, younger
+        # than three days, and it opens that one photo and nothing else.
+        ("GET", "/api/v1/notifications/photos/{filename}"),
         # Obico cached frame — one-time nonce embedded in <img> tags.
         # Obico cached frame — one-time nonce embedded in <img> tags.
         ("GET", "/api/v1/obico/cached-frame/{nonce}"),
         ("GET", "/api/v1/obico/cached-frame/{nonce}"),
         # MakerWorld thumbnail proxy — fetches external URL; no Bambuddy data exposed.
         # MakerWorld thumbnail proxy — fetches external URL; no Bambuddy data exposed.

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

@@ -3015,6 +3015,7 @@ export interface NotificationProvider {
   provider_type: ProviderType;
   provider_type: ProviderType;
   enabled: boolean;
   enabled: boolean;
   config: Record<string, unknown>;
   config: Record<string, unknown>;
+  attach_photo: boolean;
   // Print lifecycle events
   // Print lifecycle events
   on_print_start: boolean;
   on_print_start: boolean;
   on_print_complete: boolean;
   on_print_complete: boolean;
@@ -3083,6 +3084,7 @@ export interface NotificationProviderCreate {
   provider_type: ProviderType;
   provider_type: ProviderType;
   enabled?: boolean;
   enabled?: boolean;
   config: Record<string, unknown>;
   config: Record<string, unknown>;
+  attach_photo?: boolean;
   // Print lifecycle events
   // Print lifecycle events
   on_print_start?: boolean;
   on_print_start?: boolean;
   on_print_complete?: boolean;
   on_print_complete?: boolean;
@@ -3144,6 +3146,7 @@ export interface NotificationProviderUpdate {
   provider_type?: ProviderType;
   provider_type?: ProviderType;
   enabled?: boolean;
   enabled?: boolean;
   config?: Record<string, unknown>;
   config?: Record<string, unknown>;
+  attach_photo?: boolean;
   // Print lifecycle events
   // Print lifecycle events
   on_print_start?: boolean;
   on_print_start?: boolean;
   on_print_complete?: boolean;
   on_print_complete?: boolean;
@@ -3457,6 +3460,7 @@ export interface GitHubBackupTriggerResponse {
 export interface NotificationTestRequest {
 export interface NotificationTestRequest {
   provider_type: ProviderType;
   provider_type: ProviderType;
   config: Record<string, unknown>;
   config: Record<string, unknown>;
+  attach_photo?: boolean;
 }
 }
 
 
 export interface NotificationTestResponse {
 export interface NotificationTestResponse {
@@ -3518,6 +3522,7 @@ export interface EventVariablesResponse {
   event_type: string;
   event_type: string;
   event_name: string;
   event_name: string;
   variables: string[];
   variables: string[];
+  supports_photo: boolean;
 }
 }
 
 
 export interface TemplatePreviewRequest {
 export interface TemplatePreviewRequest {

+ 23 - 2
frontend/src/components/AddNotificationModal.tsx

@@ -22,6 +22,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
   const [name, setName] = useState(provider?.name || '');
   const [name, setName] = useState(provider?.name || '');
   const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type || 'email');
   const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type || 'email');
   const [printerId, setPrinterId] = useState<number | null>(provider?.printer_id || null);
   const [printerId, setPrinterId] = useState<number | null>(provider?.printer_id || null);
+  const [attachPhoto, setAttachPhoto] = useState(provider?.attach_photo ?? true);
   const [quietHoursEnabled, setQuietHoursEnabled] = useState(provider?.quiet_hours_enabled || false);
   const [quietHoursEnabled, setQuietHoursEnabled] = useState(provider?.quiet_hours_enabled || false);
   const [quietHoursStart, setQuietHoursStart] = useState(provider?.quiet_hours_start || '22:00');
   const [quietHoursStart, setQuietHoursStart] = useState(provider?.quiet_hours_start || '22:00');
   const [quietHoursEnd, setQuietHoursEnd] = useState(provider?.quiet_hours_end || '07:00');
   const [quietHoursEnd, setQuietHoursEnd] = useState(provider?.quiet_hours_end || '07:00');
@@ -106,7 +107,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
 
 
   // Test configuration mutation
   // Test configuration mutation
   const testMutation = useMutation({
   const testMutation = useMutation({
-    mutationFn: () => api.testNotificationConfig({ provider_type: providerType, config }),
+    mutationFn: () => api.testNotificationConfig({ provider_type: providerType, config, attach_photo: attachPhoto }),
     onSuccess: (result) => {
     onSuccess: (result) => {
       setTestResult(result);
       setTestResult(result);
       setError(null);
       setError(null);
@@ -192,6 +193,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       provider_type: providerType,
       provider_type: providerType,
       config: finalConfig,
       config: finalConfig,
       printer_id: printerId,
       printer_id: printerId,
+      attach_photo: attachPhoto,
       quiet_hours_enabled: quietHoursEnabled,
       quiet_hours_enabled: quietHoursEnabled,
       quiet_hours_start: quietHoursEnabled ? quietHoursStart : null,
       quiet_hours_start: quietHoursEnabled ? quietHoursStart : null,
       quiet_hours_end: quietHoursEnabled ? quietHoursEnd : null,
       quiet_hours_end: quietHoursEnabled ? quietHoursEnd : null,
@@ -319,7 +321,14 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
         ];
         ];
       case 'homeassistant':
       case 'homeassistant':
         return [
         return [
-          { key: 'service', label: 'Home Assistant Service', placeholder: 'notify.mobile_app_myphone', type: 'text', required: false },
+          {
+            key: 'service',
+            label: 'Home Assistant Service',
+            placeholder: 'notify.mobile_app_myphone',
+            type: 'text',
+            required: false,
+            help: t('notifications.haServiceHelp'),
+          },
           { key: 'data', label: 'Data (JSON, optional)', placeholder: '{"priority": "high", "ttl": 0, "channel": "3D Printing"}', type: 'textarea', required: false },
           { key: 'data', label: 'Data (JSON, optional)', placeholder: '{"priority": "high", "ttl": 0, "channel": "3D Printing"}', type: 'textarea', required: false },
         ];
         ];
       case 'bark':
       case 'bark':
@@ -548,6 +557,18 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
             </p>
             </p>
           </div>
           </div>
 
 
+          {/* Attach Photo */}
+          <div className="flex items-center justify-between">
+            <div>
+              <label className="text-sm text-white">{t('notifications.attachPhotoLabel')}</label>
+              <p className="text-xs text-bambu-gray">{t('notifications.attachPhotoDescription')}</p>
+            </div>
+            <Toggle
+              checked={attachPhoto}
+              onChange={setAttachPhoto}
+            />
+          </div>
+
           {/* Quiet Hours */}
           {/* Quiet Hours */}
           <div className="space-y-2">
           <div className="space-y-2">
             <div className="flex items-center justify-between">
             <div className="flex items-center justify-between">

+ 22 - 1
frontend/src/components/NotificationProviderCard.tsx

@@ -1,7 +1,7 @@
 import { useState } from 'react';
 import { useState } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { Bell, Trash2, Settings2, Edit2, Send, Loader2, CheckCircle, XCircle, Moon, Clock, ChevronDown, ChevronUp, Calendar } from 'lucide-react';
+import { Bell, Trash2, Settings2, Edit2, Send, Loader2, CheckCircle, XCircle, Moon, Clock, ChevronDown, ChevronUp, Calendar, Camera, CameraOff } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { formatDateOnly, parseUTCDate } from '../utils/date';
 import { formatDateOnly, parseUTCDate } from '../utils/date';
 import type { NotificationProvider, NotificationProviderUpdate } from '../api/client';
 import type { NotificationProvider, NotificationProviderUpdate } from '../api/client';
@@ -213,6 +213,12 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                 {t('notifications.digest', { time: provider.daily_digest_time })}
                 {t('notifications.digest', { time: provider.daily_digest_time })}
               </span>
               </span>
             )}
             )}
+            {provider.attach_photo === false && (
+              <span className="px-2 py-0.5 bg-bambu-dark-tertiary text-bambu-gray text-xs rounded flex items-center gap-1">
+                <CameraOff className="w-3 h-3" />
+                {t('notifications.noPhoto')}
+              </span>
+            )}
           </div>
           </div>
 
 
           {/* Test Button */}
           {/* Test Button */}
@@ -721,6 +727,21 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                 )}
                 )}
               </div>
               </div>
 
 
+              {/* Attach Photo */}
+              <div className="flex items-center justify-between">
+                <div className="flex items-center gap-2">
+                  <Camera className="w-4 h-4 text-bambu-gray" />
+                  <div>
+                    <p className="text-sm text-white">{t('notifications.attachPhotoLabel')}</p>
+                    <p className="text-xs text-bambu-gray">{t('notifications.attachPhotoDescription')}</p>
+                  </div>
+                </div>
+                <Toggle
+                  checked={provider.attach_photo ?? true}
+                  onChange={(checked) => updateMutation.mutate({ attach_photo: checked })}
+                />
+              </div>
+
               {/* Action Buttons */}
               {/* Action Buttons */}
               <div className="flex gap-2 pt-2">
               <div className="flex gap-2 pt-2">
                 <Button
                 <Button

+ 16 - 0
frontend/src/components/NotificationTemplateEditor.tsx

@@ -227,6 +227,22 @@ export function NotificationTemplateEditor({ template, onClose }: NotificationTe
                       <span className="text-xs text-bambu-gray">{t('notifications.bodyPreview')}</span>
                       <span className="text-xs text-bambu-gray">{t('notifications.bodyPreview')}</span>
                       <div className="text-white whitespace-pre-wrap text-sm">{preview.body}</div>
                       <div className="text-white whitespace-pre-wrap text-sm">{preview.body}</div>
                     </div>
                     </div>
+                    {eventVariables?.supports_photo && (
+                      <div className="pt-2 mt-2 border-t border-bambu-dark-tertiary/60 flex items-center gap-3">
+                        <img
+                          src="/img/screenshot-desktop.png"
+                          alt={t('notifications.photoPreview')}
+                          className="w-24 h-14 object-cover rounded border border-bambu-dark-tertiary shrink-0"
+                        />
+                        <p className="text-xs text-bambu-gray">
+                          {template.event_type.startsWith('user_')
+                            ? t('notifications.photoPreviewNoteEmailOnly')
+                            : eventVariables.variables.includes('finish_photo_url')
+                              ? t('notifications.photoPreviewNoteWithEmail')
+                              : t('notifications.photoPreviewNote')}
+                        </p>
+                      </div>
+                    )}
                   </>
                   </>
                 ) : (
                 ) : (
                   <div className="text-bambu-gray text-sm">
                   <div className="text-bambu-gray text-sm">

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

@@ -6383,6 +6383,7 @@ export default {
     firstLayer: 'Erste Schicht',
     firstLayer: 'Erste Schicht',
     quiet: 'Ruhe',
     quiet: 'Ruhe',
     digest: 'Zusammenfassung {{time}}',
     digest: 'Zusammenfassung {{time}}',
+    noPhoto: 'Kein Foto',
     // Event labels (expanded settings)
     // Event labels (expanded settings)
     printStarted: 'Druck gestartet',
     printStarted: 'Druck gestartet',
     plateNotEmpty: 'Platte nicht leer',
     plateNotEmpty: 'Platte nicht leer',
@@ -6471,6 +6472,8 @@ export default {
     testConfiguration: 'Konfiguration testen',
     testConfiguration: 'Konfiguration testen',
     printerFilter: 'Druckerfilter',
     printerFilter: 'Druckerfilter',
     onlyFromPrinter: 'Nur Benachrichtigungen für Ereignisse von diesem Drucker senden',
     onlyFromPrinter: 'Nur Benachrichtigungen für Ereignisse von diesem Drucker senden',
+    attachPhotoLabel: 'Foto anhängen',
+    attachPhotoDescription: 'Einen Kamera-Schnappschuss mitsenden, sofern für das Ereignis einer aufgenommen wurde',
     quietHoursDnd: 'Ruhezeiten (Nicht stören)',
     quietHoursDnd: 'Ruhezeiten (Nicht stören)',
     quietStart: 'Start',
     quietStart: 'Start',
     quietEnd: 'Ende',
     quietEnd: 'Ende',
@@ -6500,6 +6503,7 @@ export default {
     nameRequired: 'Name ist erforderlich',
     nameRequired: 'Name ist erforderlich',
     fieldRequired: '{{field}} ist erforderlich',
     fieldRequired: '{{field}} ist erforderlich',
     haDataInvalid: 'Das Datenfeld muss ein gültiges JSON-Objekt sein, z. B. {"priority": "high", "ttl": 0}',
     haDataInvalid: 'Das Datenfeld muss ein gültiges JSON-Objekt sein, z. B. {"priority": "high", "ttl": 0}',
+    haServiceHelp: 'Optional — standardmäßig eine dauerhafte Benachrichtigung in der Home-Assistant-Oberfläche. Lege einen Notify-Dienst fest (z. B. notify.mobile_app_myphone), um Push-Benachrichtigungen auf dem Handy zu erhalten; dann wird automatisch ein Kamera-Schnappschuss angehängt, sofern einer vorhanden ist und unter Einstellungen → Netzwerk eine Externe URL eingetragen ist.',
     // Config field labels
     // Config field labels
     phoneNumber: 'Telefonnummer',
     phoneNumber: 'Telefonnummer',
     apiKey: 'API-Schlüssel',
     apiKey: 'API-Schlüssel',
@@ -6551,6 +6555,10 @@ export default {
     enterTemplateContent: 'Vorlageninhalt eingeben, um Vorschau zu sehen',
     enterTemplateContent: 'Vorlageninhalt eingeben, um Vorschau zu sehen',
     titlePreview: 'Titel:',
     titlePreview: 'Titel:',
     bodyPreview: 'Inhalt:',
     bodyPreview: 'Inhalt:',
+    photoPreview: 'Foto:',
+    photoPreviewNote: 'Nur ein Beispiel — Dienste, die Anhänge unterstützen (ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook), fügen automatisch einen Kamera-Schnappschuss hinzu, wenn einer aufgenommen wurde, unabhängig vom Text oben.',
+    photoPreviewNoteWithEmail: 'Nur ein Beispiel — wird bei unterstützten Diensten (ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook) automatisch angehängt, wenn ein Schnappschuss aufgenommen wurde. Für E-Mail füge {finish_photo_url} in den Text oben ein, um das Foto eingebettet zu senden.',
+    photoPreviewNoteEmailOnly: 'Nur ein Beispiel — dies ist immer eine E-Mail. Füge {finish_photo_url} in den Text oben ein, um das Foto eingebettet zu senden; ohne die Variable wird nur der Text verschickt.',
     resetToDefault: 'Auf Standard zurücksetzen',
     resetToDefault: 'Auf Standard zurücksetzen',
     titleRequired: 'Titel ist erforderlich',
     titleRequired: 'Titel ist erforderlich',
     bodyRequired: 'Inhalt ist erforderlich',
     bodyRequired: 'Inhalt ist erforderlich',

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

@@ -6434,6 +6434,7 @@ export default {
     firstLayer: 'First Layer',
     firstLayer: 'First Layer',
     quiet: 'Quiet',
     quiet: 'Quiet',
     digest: 'Digest {{time}}',
     digest: 'Digest {{time}}',
+    noPhoto: 'No Photo',
     // Event labels (expanded settings)
     // Event labels (expanded settings)
     printStarted: 'Print Started',
     printStarted: 'Print Started',
     plateNotEmpty: 'Plate Not Empty',
     plateNotEmpty: 'Plate Not Empty',
@@ -6522,6 +6523,8 @@ export default {
     testConfiguration: 'Test Configuration',
     testConfiguration: 'Test Configuration',
     printerFilter: 'Printer Filter',
     printerFilter: 'Printer Filter',
     onlyFromPrinter: 'Only send notifications for events from this printer',
     onlyFromPrinter: 'Only send notifications for events from this printer',
+    attachPhotoLabel: 'Attach Photo',
+    attachPhotoDescription: 'Include a camera snapshot with notifications, when one was captured for the event',
     quietHoursDnd: 'Quiet Hours (Do Not Disturb)',
     quietHoursDnd: 'Quiet Hours (Do Not Disturb)',
     quietStart: 'Start',
     quietStart: 'Start',
     quietEnd: 'End',
     quietEnd: 'End',
@@ -6551,6 +6554,7 @@ export default {
     nameRequired: 'Name is required',
     nameRequired: 'Name is required',
     fieldRequired: '{{field}} is required',
     fieldRequired: '{{field}} is required',
     haDataInvalid: 'The Data field must be a valid JSON object, e.g. {"priority": "high", "ttl": 0}',
     haDataInvalid: 'The Data field must be a valid JSON object, e.g. {"priority": "high", "ttl": 0}',
+    haServiceHelp: "Optional — defaults to a persistent notification in Home Assistant's UI. Set a notify service (e.g. notify.mobile_app_myphone) to get push notifications on your phone, which also attaches a camera snapshot automatically when one is available and External URL is set in Settings → Network.",
     // Config field labels
     // Config field labels
     phoneNumber: 'Phone Number',
     phoneNumber: 'Phone Number',
     apiKey: 'API Key',
     apiKey: 'API Key',
@@ -6602,6 +6606,10 @@ export default {
     enterTemplateContent: 'Enter template content to see preview',
     enterTemplateContent: 'Enter template content to see preview',
     titlePreview: 'Title:',
     titlePreview: 'Title:',
     bodyPreview: 'Body:',
     bodyPreview: 'Body:',
+    photoPreview: 'Photo:',
+    photoPreviewNote: 'Example only — providers that support attachments (ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook) include a camera snapshot automatically when one was captured, regardless of what the body above says.',
+    photoPreviewNoteWithEmail: 'Example only — attached automatically for supported providers (ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook) when a snapshot was captured. For Email, add {finish_photo_url} to the body above to include it inline.',
+    photoPreviewNoteEmailOnly: 'Example only — this is always an email. Add {finish_photo_url} to the body above to include the photo inline; without it, only the text is sent.',
     resetToDefault: 'Reset to Default',
     resetToDefault: 'Reset to Default',
     titleRequired: 'Title is required',
     titleRequired: 'Title is required',
     bodyRequired: 'Body is required',
     bodyRequired: 'Body is required',

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

@@ -6391,6 +6391,7 @@ export default {
     firstLayer: 'Primera capa',
     firstLayer: 'Primera capa',
     quiet: 'Silencio',
     quiet: 'Silencio',
     digest: 'Resumen {{time}}',
     digest: 'Resumen {{time}}',
+    noPhoto: 'Sin foto',
     // Event labels (expanded settings)
     // Event labels (expanded settings)
     printStarted: 'Impresión iniciada',
     printStarted: 'Impresión iniciada',
     plateNotEmpty: 'Cama no vacía',
     plateNotEmpty: 'Cama no vacía',
@@ -6479,6 +6480,8 @@ export default {
     testConfiguration: 'Probar configuración',
     testConfiguration: 'Probar configuración',
     printerFilter: 'Filtro de impresora',
     printerFilter: 'Filtro de impresora',
     onlyFromPrinter: 'Enviar notificaciones solo para los eventos de esta impresora',
     onlyFromPrinter: 'Enviar notificaciones solo para los eventos de esta impresora',
+    attachPhotoLabel: 'Adjuntar foto',
+    attachPhotoDescription: 'Incluir una captura de la cámara en las notificaciones, cuando se haya tomado una para el evento',
     quietHoursDnd: 'Horas de silencio (no molestar)',
     quietHoursDnd: 'Horas de silencio (no molestar)',
     quietStart: 'Inicio',
     quietStart: 'Inicio',
     quietEnd: 'Fin',
     quietEnd: 'Fin',
@@ -6508,6 +6511,7 @@ export default {
     nameRequired: 'El nombre es obligatorio',
     nameRequired: 'El nombre es obligatorio',
     fieldRequired: '{{field}} es obligatorio',
     fieldRequired: '{{field}} es obligatorio',
     haDataInvalid: 'El campo Data debe ser un objeto JSON válido, p. ej. {"priority": "high", "ttl": 0}',
     haDataInvalid: 'El campo Data debe ser un objeto JSON válido, p. ej. {"priority": "high", "ttl": 0}',
+    haServiceHelp: 'Opcional: por defecto, una notificación persistente en la interfaz de Home Assistant. Indica un servicio notify (p. ej. notify.mobile_app_myphone) para recibir notificaciones push en el móvil, que además adjuntan automáticamente una captura de la cámara cuando la hay y se ha configurado la URL externa en Ajustes → Red.',
     // Config field labels
     // Config field labels
     phoneNumber: 'Número de teléfono',
     phoneNumber: 'Número de teléfono',
     apiKey: 'Clave API',
     apiKey: 'Clave API',
@@ -6559,6 +6563,10 @@ export default {
     enterTemplateContent: 'Introduzca el contenido de la plantilla para ver la vista previa',
     enterTemplateContent: 'Introduzca el contenido de la plantilla para ver la vista previa',
     titlePreview: 'Título:',
     titlePreview: 'Título:',
     bodyPreview: 'Cuerpo:',
     bodyPreview: 'Cuerpo:',
+    photoPreview: 'Foto:',
+    photoPreviewNote: 'Solo es un ejemplo: los proveedores que admiten adjuntos (ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook) incluyen automáticamente una captura de la cámara cuando se ha tomado una, diga lo que diga el cuerpo de arriba.',
+    photoPreviewNoteWithEmail: 'Solo es un ejemplo: se adjunta automáticamente en los proveedores compatibles (ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook) cuando se ha tomado una captura. Para el correo electrónico, añade {finish_photo_url} al cuerpo de arriba para incluirla en línea.',
+    photoPreviewNoteEmailOnly: 'Solo es un ejemplo: esto siempre es un correo electrónico. Añade {finish_photo_url} al cuerpo de arriba para incluir la foto en línea; sin ella solo se envía el texto.',
     resetToDefault: 'Restablecer al valor predeterminado',
     resetToDefault: 'Restablecer al valor predeterminado',
     titleRequired: 'El título es obligatorio',
     titleRequired: 'El título es obligatorio',
     bodyRequired: 'El cuerpo es obligatorio',
     bodyRequired: 'El cuerpo es obligatorio',

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

@@ -6371,6 +6371,7 @@ export default {
     firstLayer: 'Première couche',
     firstLayer: 'Première couche',
     quiet: 'Silencieux',
     quiet: 'Silencieux',
     digest: 'Résumé {{time}}',
     digest: 'Résumé {{time}}',
+    noPhoto: 'Pas de photo',
     // Event labels (expanded settings)
     // Event labels (expanded settings)
     printStarted: 'Impression démarrée',
     printStarted: 'Impression démarrée',
     plateNotEmpty: 'Plateau non vide',
     plateNotEmpty: 'Plateau non vide',
@@ -6459,6 +6460,8 @@ export default {
     testConfiguration: 'Tester la configuration',
     testConfiguration: 'Tester la configuration',
     printerFilter: 'Filtre d\'imprimante',
     printerFilter: 'Filtre d\'imprimante',
     onlyFromPrinter: 'Envoyer uniquement les notifications pour les événements de cette imprimante',
     onlyFromPrinter: 'Envoyer uniquement les notifications pour les événements de cette imprimante',
+    attachPhotoLabel: 'Joindre une photo',
+    attachPhotoDescription: 'Inclure une capture de la caméra dans les notifications, lorsqu\'une capture a été prise pour l\'événement',
     quietHoursDnd: 'Heures silencieuses (Ne pas déranger)',
     quietHoursDnd: 'Heures silencieuses (Ne pas déranger)',
     quietStart: 'Début',
     quietStart: 'Début',
     quietEnd: 'Fin',
     quietEnd: 'Fin',
@@ -6488,6 +6491,7 @@ export default {
     nameRequired: 'Le nom est requis',
     nameRequired: 'Le nom est requis',
     fieldRequired: '{{field}} est requis',
     fieldRequired: '{{field}} est requis',
     haDataInvalid: 'Le champ Data doit être un objet JSON valide, p. ex. {"priority": "high", "ttl": 0}',
     haDataInvalid: 'Le champ Data doit être un objet JSON valide, p. ex. {"priority": "high", "ttl": 0}',
+    haServiceHelp: 'Facultatif — par défaut, une notification persistante dans l\'interface de Home Assistant. Indiquez un service notify (p. ex. notify.mobile_app_myphone) pour recevoir des notifications push sur votre téléphone ; une capture de la caméra y est alors jointe automatiquement lorsqu\'elle est disponible et que l\'URL externe est définie dans Paramètres → Réseau.',
     // Config field labels
     // Config field labels
     phoneNumber: 'Numéro de téléphone',
     phoneNumber: 'Numéro de téléphone',
     apiKey: 'Clé API',
     apiKey: 'Clé API',
@@ -6539,6 +6543,10 @@ export default {
     enterTemplateContent: 'Saisir le contenu du modèle pour voir l\'aperçu',
     enterTemplateContent: 'Saisir le contenu du modèle pour voir l\'aperçu',
     titlePreview: 'Titre :',
     titlePreview: 'Titre :',
     bodyPreview: 'Corps :',
     bodyPreview: 'Corps :',
+    photoPreview: 'Photo :',
+    photoPreviewNote: 'Exemple uniquement — les fournisseurs qui acceptent les pièces jointes (ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook) incluent automatiquement une capture de la caméra lorsqu\'une capture a été prise, quel que soit le contenu du corps ci-dessus.',
+    photoPreviewNoteWithEmail: 'Exemple uniquement — jointe automatiquement pour les fournisseurs compatibles (ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook) lorsqu\'une capture a été prise. Pour l\'e-mail, ajoutez {finish_photo_url} au corps ci-dessus pour l\'intégrer.',
+    photoPreviewNoteEmailOnly: 'Exemple uniquement — il s\'agit toujours d\'un e-mail. Ajoutez {finish_photo_url} au corps ci-dessus pour intégrer la photo ; sans cela, seul le texte est envoyé.',
     resetToDefault: 'Réinitialiser par défaut',
     resetToDefault: 'Réinitialiser par défaut',
     titleRequired: 'Le titre est requis',
     titleRequired: 'Le titre est requis',
     bodyRequired: 'Le corps est requis',
     bodyRequired: 'Le corps est requis',

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

@@ -6370,6 +6370,7 @@ export default {
     firstLayer: 'Primo strato',
     firstLayer: 'Primo strato',
     quiet: 'Silenzioso',
     quiet: 'Silenzioso',
     digest: 'Riepilogo {{time}}',
     digest: 'Riepilogo {{time}}',
+    noPhoto: 'Nessuna foto',
     // Event labels (expanded settings)
     // Event labels (expanded settings)
     printStarted: 'Stampa avviata',
     printStarted: 'Stampa avviata',
     plateNotEmpty: 'Piatto non vuoto',
     plateNotEmpty: 'Piatto non vuoto',
@@ -6458,6 +6459,8 @@ export default {
     testConfiguration: 'Testa configurazione',
     testConfiguration: 'Testa configurazione',
     printerFilter: 'Filtro stampante',
     printerFilter: 'Filtro stampante',
     onlyFromPrinter: 'Invia notifiche solo per eventi da questa stampante',
     onlyFromPrinter: 'Invia notifiche solo per eventi da questa stampante',
+    attachPhotoLabel: 'Allega foto',
+    attachPhotoDescription: 'Includi un\'istantanea della fotocamera nelle notifiche, quando ne è stata acquisita una per l\'evento',
     quietHoursDnd: 'Ore silenziose (Non disturbare)',
     quietHoursDnd: 'Ore silenziose (Non disturbare)',
     quietStart: 'Inizio',
     quietStart: 'Inizio',
     quietEnd: 'Fine',
     quietEnd: 'Fine',
@@ -6487,6 +6490,7 @@ export default {
     nameRequired: 'Il nome è obbligatorio',
     nameRequired: 'Il nome è obbligatorio',
     fieldRequired: '{{field}} è obbligatorio',
     fieldRequired: '{{field}} è obbligatorio',
     haDataInvalid: 'Il campo Data deve essere un oggetto JSON valido, ad es. {"priority": "high", "ttl": 0}',
     haDataInvalid: 'Il campo Data deve essere un oggetto JSON valido, ad es. {"priority": "high", "ttl": 0}',
+    haServiceHelp: 'Facoltativo — per impostazione predefinita una notifica persistente nell\'interfaccia di Home Assistant. Imposta un servizio notify (ad es. notify.mobile_app_myphone) per ricevere notifiche push sul telefono, che allegano automaticamente anche un\'istantanea della fotocamera quando disponibile e se l\'URL esterno è impostato in Impostazioni → Rete.',
     // Config field labels
     // Config field labels
     phoneNumber: 'Numero di telefono',
     phoneNumber: 'Numero di telefono',
     apiKey: 'Chiave API',
     apiKey: 'Chiave API',
@@ -6538,6 +6542,10 @@ export default {
     enterTemplateContent: 'Inserisci il contenuto del modello per vedere l\'anteprima',
     enterTemplateContent: 'Inserisci il contenuto del modello per vedere l\'anteprima',
     titlePreview: 'Titolo:',
     titlePreview: 'Titolo:',
     bodyPreview: 'Corpo:',
     bodyPreview: 'Corpo:',
+    photoPreview: 'Foto:',
+    photoPreviewNote: 'Solo un esempio — i provider che supportano gli allegati (ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook) includono automaticamente un\'istantanea della fotocamera quando ne è stata acquisita una, indipendentemente dal testo qui sopra.',
+    photoPreviewNoteWithEmail: 'Solo un esempio — allegata automaticamente per i provider supportati (ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook) quando è stata acquisita un\'istantanea. Per l\'email, aggiungi {finish_photo_url} al testo qui sopra per includerla in linea.',
+    photoPreviewNoteEmailOnly: 'Solo un esempio — questa è sempre un\'email. Aggiungi {finish_photo_url} al testo qui sopra per includere la foto in linea; senza, viene inviato solo il testo.',
     resetToDefault: 'Ripristina predefinito',
     resetToDefault: 'Ripristina predefinito',
     titleRequired: 'Il titolo è obbligatorio',
     titleRequired: 'Il titolo è obbligatorio',
     bodyRequired: 'Il corpo è obbligatorio',
     bodyRequired: 'Il corpo è obbligatorio',

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

@@ -6384,6 +6384,7 @@ export default {
     firstLayer: '第1層完了',
     firstLayer: '第1層完了',
     quiet: '静音',
     quiet: '静音',
     digest: 'ダイジェスト {{time}}',
     digest: 'ダイジェスト {{time}}',
+    noPhoto: '写真なし',
     // Event labels (expanded settings)
     // Event labels (expanded settings)
     printStarted: '印刷開始',
     printStarted: '印刷開始',
     plateNotEmpty: 'プレートが空でない',
     plateNotEmpty: 'プレートが空でない',
@@ -6472,6 +6473,8 @@ export default {
     testConfiguration: '設定をテスト',
     testConfiguration: '設定をテスト',
     printerFilter: 'プリンターフィルター',
     printerFilter: 'プリンターフィルター',
     onlyFromPrinter: 'このプリンターからのイベントのみ通知を送信',
     onlyFromPrinter: 'このプリンターからのイベントのみ通知を送信',
+    attachPhotoLabel: '写真を添付',
+    attachPhotoDescription: 'イベントでカメラのスナップショットが撮影された場合、通知に添付します',
     quietHoursDnd: '静音時間(おやすみモード)',
     quietHoursDnd: '静音時間(おやすみモード)',
     quietStart: '開始',
     quietStart: '開始',
     quietEnd: '終了',
     quietEnd: '終了',
@@ -6501,6 +6504,7 @@ export default {
     nameRequired: '名前は必須です',
     nameRequired: '名前は必須です',
     fieldRequired: '{{field}}は必須です',
     fieldRequired: '{{field}}は必須です',
     haDataInvalid: 'Dataフィールドは有効なJSONオブジェクトである必要があります(例: {"priority": "high", "ttl": 0})',
     haDataInvalid: 'Dataフィールドは有効なJSONオブジェクトである必要があります(例: {"priority": "high", "ttl": 0})',
+    haServiceHelp: '任意 — 既定では Home Assistant の UI に永続通知として表示されます。notify サービス(例: notify.mobile_app_myphone)を指定するとスマートフォンにプッシュ通知が届き、スナップショットがあり 設定 → ネットワーク で外部URLが設定されていれば、カメラのスナップショットも自動で添付されます。',
     // Config field labels
     // Config field labels
     phoneNumber: '電話番号',
     phoneNumber: '電話番号',
     apiKey: 'APIキー',
     apiKey: 'APIキー',
@@ -6552,6 +6556,10 @@ export default {
     enterTemplateContent: 'テンプレートの内容を入力するとプレビューが表示されます',
     enterTemplateContent: 'テンプレートの内容を入力するとプレビューが表示されます',
     titlePreview: 'タイトル:',
     titlePreview: 'タイトル:',
     bodyPreview: '本文:',
     bodyPreview: '本文:',
+    photoPreview: '写真:',
+    photoPreviewNote: '例のみ — 添付に対応したプロバイダー(ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook)では、スナップショットが撮影されていれば、上の本文の内容にかかわらず自動で添付されます。',
+    photoPreviewNoteWithEmail: '例のみ — スナップショットが撮影されていれば、対応プロバイダー(ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook)には自動で添付されます。メールでインライン表示するには、上の本文に {finish_photo_url} を追加してください。',
+    photoPreviewNoteEmailOnly: '例のみ — これは常にメールです。写真をインライン表示するには上の本文に {finish_photo_url} を追加してください。追加しない場合はテキストのみ送信されます。',
     resetToDefault: 'デフォルトにリセット',
     resetToDefault: 'デフォルトにリセット',
     titleRequired: 'タイトルは必須です',
     titleRequired: 'タイトルは必須です',
     bodyRequired: '本文は必須です',
     bodyRequired: '本文は必須です',

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

@@ -6100,6 +6100,7 @@ export default {
     firstLayer: '첫 번째 레이어',
     firstLayer: '첫 번째 레이어',
     quiet: '방해 금지',
     quiet: '방해 금지',
     digest: '요약 {{time}}',
     digest: '요약 {{time}}',
+    noPhoto: '사진 없음',
     printStarted: '인쇄 시작됨',
     printStarted: '인쇄 시작됨',
     plateNotEmpty: '플레이트 비어 있지 않음',
     plateNotEmpty: '플레이트 비어 있지 않음',
     plateNotEmptyDescription: '인쇄 전 개체 감지됨',
     plateNotEmptyDescription: '인쇄 전 개체 감지됨',
@@ -6181,6 +6182,8 @@ export default {
     testConfiguration: '구성 테스트',
     testConfiguration: '구성 테스트',
     printerFilter: '프린터 필터',
     printerFilter: '프린터 필터',
     onlyFromPrinter: '이 프린터의 이벤트에 대한 알림만 전송',
     onlyFromPrinter: '이 프린터의 이벤트에 대한 알림만 전송',
+    attachPhotoLabel: '사진 첨부',
+    attachPhotoDescription: '이벤트에 대해 카메라 스냅샷이 촬영된 경우 알림에 포함합니다',
     quietHoursDnd: '방해 금지 시간',
     quietHoursDnd: '방해 금지 시간',
     quietStart: '시작',
     quietStart: '시작',
     quietEnd: '종료',
     quietEnd: '종료',
@@ -6209,6 +6212,7 @@ export default {
     nameRequired: '이름이 필요합니다',
     nameRequired: '이름이 필요합니다',
     fieldRequired: '{{field}}이(가) 필요합니다',
     fieldRequired: '{{field}}이(가) 필요합니다',
     haDataInvalid: 'Data 필드는 유효한 JSON 객체여야 합니다(예: {"priority": "high", "ttl": 0})',
     haDataInvalid: 'Data 필드는 유효한 JSON 객체여야 합니다(예: {"priority": "high", "ttl": 0})',
+    haServiceHelp: '선택 사항 — 기본값은 Home Assistant UI의 지속 알림입니다. notify 서비스(예: notify.mobile_app_myphone)를 지정하면 휴대폰으로 푸시 알림을 받으며, 스냅샷이 있고 설정 → 네트워크에 외부 URL이 설정되어 있으면 카메라 스냅샷도 자동으로 첨부됩니다.',
     phoneNumber: '전화번호',
     phoneNumber: '전화번호',
     apiKey: 'API 키',
     apiKey: 'API 키',
     serverUrl: '서버 URL',
     serverUrl: '서버 URL',
@@ -6258,6 +6262,10 @@ export default {
     enterTemplateContent: '미리보기를 보려면 템플릿 내용을 입력하세요',
     enterTemplateContent: '미리보기를 보려면 템플릿 내용을 입력하세요',
     titlePreview: '제목:',
     titlePreview: '제목:',
     bodyPreview: '본문:',
     bodyPreview: '본문:',
+    photoPreview: '사진:',
+    photoPreviewNote: '예시일 뿐입니다 — 첨부를 지원하는 제공자(ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook)는 스냅샷이 촬영되면 위 본문 내용과 관계없이 카메라 스냅샷을 자동으로 포함합니다.',
+    photoPreviewNoteWithEmail: '예시일 뿐입니다 — 스냅샷이 촬영되면 지원되는 제공자(ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook)에 자동으로 첨부됩니다. 이메일에서 인라인으로 포함하려면 위 본문에 {finish_photo_url}을(를) 추가하세요.',
+    photoPreviewNoteEmailOnly: '예시일 뿐입니다 — 이 알림은 항상 이메일입니다. 사진을 인라인으로 포함하려면 위 본문에 {finish_photo_url}을(를) 추가하세요. 없으면 텍스트만 전송됩니다.',
     resetToDefault: '기본값으로 초기화',
     resetToDefault: '기본값으로 초기화',
     titleRequired: '제목이 필요합니다',
     titleRequired: '제목이 필요합니다',
     bodyRequired: '본문이 필요합니다',
     bodyRequired: '본문이 필요합니다',

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

@@ -6434,6 +6434,7 @@ export default {
     firstLayer: 'Eerste laag',
     firstLayer: 'Eerste laag',
     quiet: 'Stil',
     quiet: 'Stil',
     digest: 'Samenvatting {{time}}',
     digest: 'Samenvatting {{time}}',
+    noPhoto: 'Geen foto',
     // Event labels (expanded settings)
     // Event labels (expanded settings)
     printStarted: 'Afdruk gestart',
     printStarted: 'Afdruk gestart',
     plateNotEmpty: 'Plaat niet leeg',
     plateNotEmpty: 'Plaat niet leeg',
@@ -6522,6 +6523,8 @@ export default {
     testConfiguration: 'Configuratie testen',
     testConfiguration: 'Configuratie testen',
     printerFilter: 'Printerfilter',
     printerFilter: 'Printerfilter',
     onlyFromPrinter: 'Alleen meldingen verzenden voor gebeurtenissen van deze printer',
     onlyFromPrinter: 'Alleen meldingen verzenden voor gebeurtenissen van deze printer',
+    attachPhotoLabel: 'Foto bijvoegen',
+    attachPhotoDescription: 'Een camera-momentopname meesturen met meldingen, als er een is gemaakt voor de gebeurtenis',
     quietHoursDnd: 'Stille uren (Niet storen)',
     quietHoursDnd: 'Stille uren (Niet storen)',
     quietStart: 'Start',
     quietStart: 'Start',
     quietEnd: 'Einde',
     quietEnd: 'Einde',
@@ -6551,6 +6554,7 @@ export default {
     nameRequired: 'Naam is verplicht',
     nameRequired: 'Naam is verplicht',
     fieldRequired: '{{field}} is verplicht',
     fieldRequired: '{{field}} is verplicht',
     haDataInvalid: 'Het veld Data moet een geldig JSON-object zijn, bijv. {"priority": "high", "ttl": 0}',
     haDataInvalid: 'Het veld Data moet een geldig JSON-object zijn, bijv. {"priority": "high", "ttl": 0}',
+    haServiceHelp: 'Optioneel — standaard een blijvende melding in de interface van Home Assistant. Stel een notify-service in (bijv. notify.mobile_app_myphone) om pushmeldingen op je telefoon te krijgen; daarbij wordt ook automatisch een camera-momentopname bijgevoegd als die er is en de Externe URL is ingesteld onder Instellingen → Netwerk.',
     // Config field labels
     // Config field labels
     phoneNumber: 'Telefoonnummer',
     phoneNumber: 'Telefoonnummer',
     apiKey: 'API-sleutel',
     apiKey: 'API-sleutel',
@@ -6602,6 +6606,10 @@ export default {
     enterTemplateContent: 'Voer sjablooninhoud in om een voorbeeld te zien',
     enterTemplateContent: 'Voer sjablooninhoud in om een voorbeeld te zien',
     titlePreview: 'Titel:',
     titlePreview: 'Titel:',
     bodyPreview: 'Inhoud:',
     bodyPreview: 'Inhoud:',
+    photoPreview: 'Foto:',
+    photoPreviewNote: 'Alleen een voorbeeld — providers die bijlagen ondersteunen (ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook) voegen automatisch een camera-momentopname toe als er een is gemaakt, ongeacht wat er hierboven in de tekst staat.',
+    photoPreviewNoteWithEmail: 'Alleen een voorbeeld — wordt automatisch bijgevoegd bij ondersteunde providers (ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook) als er een momentopname is gemaakt. Voeg voor e-mail {finish_photo_url} toe aan de tekst hierboven om de foto in te voegen.',
+    photoPreviewNoteEmailOnly: 'Alleen een voorbeeld — dit is altijd een e-mail. Voeg {finish_photo_url} toe aan de tekst hierboven om de foto in te voegen; zonder wordt alleen de tekst verzonden.',
     resetToDefault: 'Terugzetten naar standaard',
     resetToDefault: 'Terugzetten naar standaard',
     titleRequired: 'Titel is verplicht',
     titleRequired: 'Titel is verplicht',
     bodyRequired: 'Inhoud is verplicht',
     bodyRequired: 'Inhoud is verplicht',

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

@@ -6370,6 +6370,7 @@ export default {
     firstLayer: 'Primeira camada',
     firstLayer: 'Primeira camada',
     quiet: 'Silencioso',
     quiet: 'Silencioso',
     digest: 'Resumo {{time}}',
     digest: 'Resumo {{time}}',
+    noPhoto: 'Sem foto',
     // Event labels (expanded settings)
     // Event labels (expanded settings)
     printStarted: 'Impressão Iniciada',
     printStarted: 'Impressão Iniciada',
     plateNotEmpty: 'Mesa Não Vazia',
     plateNotEmpty: 'Mesa Não Vazia',
@@ -6458,6 +6459,8 @@ export default {
     testConfiguration: 'Testar Configuração',
     testConfiguration: 'Testar Configuração',
     printerFilter: 'Filtro de Impressora',
     printerFilter: 'Filtro de Impressora',
     onlyFromPrinter: 'Enviar notificações apenas para eventos desta impressora',
     onlyFromPrinter: 'Enviar notificações apenas para eventos desta impressora',
+    attachPhotoLabel: 'Anexar foto',
+    attachPhotoDescription: 'Incluir uma captura da câmera nas notificações, quando uma tiver sido feita para o evento',
     quietHoursDnd: 'Horário de Silêncio (Não Perturbe)',
     quietHoursDnd: 'Horário de Silêncio (Não Perturbe)',
     quietStart: 'Início',
     quietStart: 'Início',
     quietEnd: 'Fim',
     quietEnd: 'Fim',
@@ -6487,6 +6490,7 @@ export default {
     nameRequired: 'Nome é obrigatório',
     nameRequired: 'Nome é obrigatório',
     fieldRequired: '{{field}} é obrigatório',
     fieldRequired: '{{field}} é obrigatório',
     haDataInvalid: 'O campo Data deve ser um objeto JSON válido, por ex. {"priority": "high", "ttl": 0}',
     haDataInvalid: 'O campo Data deve ser um objeto JSON válido, por ex. {"priority": "high", "ttl": 0}',
+    haServiceHelp: 'Opcional — por padrão, uma notificação persistente na interface do Home Assistant. Defina um serviço notify (ex.: notify.mobile_app_myphone) para receber notificações push no celular, que também anexam automaticamente uma captura da câmera quando houver uma e a URL externa estiver definida em Configurações → Rede.',
     // Config field labels
     // Config field labels
     phoneNumber: 'Número de Telefone',
     phoneNumber: 'Número de Telefone',
     apiKey: 'Chave da API',
     apiKey: 'Chave da API',
@@ -6538,6 +6542,10 @@ export default {
     enterTemplateContent: 'Insira o conteúdo do modelo para ver a pré-visualização',
     enterTemplateContent: 'Insira o conteúdo do modelo para ver a pré-visualização',
     titlePreview: 'Título:',
     titlePreview: 'Título:',
     bodyPreview: 'Corpo:',
     bodyPreview: 'Corpo:',
+    photoPreview: 'Foto:',
+    photoPreviewNote: 'Apenas um exemplo — provedores que aceitam anexos (ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook) incluem automaticamente uma captura da câmera quando uma foi feita, independentemente do que diz o corpo acima.',
+    photoPreviewNoteWithEmail: 'Apenas um exemplo — anexada automaticamente nos provedores compatíveis (ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook) quando uma captura foi feita. Para e-mail, adicione {finish_photo_url} ao corpo acima para incluí-la no corpo da mensagem.',
+    photoPreviewNoteEmailOnly: 'Apenas um exemplo — isto é sempre um e-mail. Adicione {finish_photo_url} ao corpo acima para incluir a foto na mensagem; sem isso, apenas o texto é enviado.',
     resetToDefault: 'Restaurar Padrão',
     resetToDefault: 'Restaurar Padrão',
     titleRequired: 'Título é obrigatório',
     titleRequired: 'Título é obrigatório',
     bodyRequired: 'Corpo é obrigatório',
     bodyRequired: 'Corpo é obrigatório',

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

@@ -6087,6 +6087,7 @@ export default {
     firstLayer: "Первый слой",
     firstLayer: "Первый слой",
     quiet: "Тихий режим",
     quiet: "Тихий режим",
     digest: "Сводка в {{time}}",
     digest: "Сводка в {{time}}",
+    noPhoto: "Без фото",
     printStarted: "Печать началась",
     printStarted: "Печать началась",
     plateNotEmpty: "Пластина не пуста",
     plateNotEmpty: "Пластина не пуста",
     plateNotEmptyDescription: "Перед печатью на пластине обнаружены объекты",
     plateNotEmptyDescription: "Перед печатью на пластине обнаружены объекты",
@@ -6168,6 +6169,8 @@ export default {
     testConfiguration: "Проверить настройку",
     testConfiguration: "Проверить настройку",
     printerFilter: "Фильтр принтеров",
     printerFilter: "Фильтр принтеров",
     onlyFromPrinter: "Отправлять уведомления только о событиях этого принтера",
     onlyFromPrinter: "Отправлять уведомления только о событиях этого принтера",
+    attachPhotoLabel: "Прикреплять фото",
+    attachPhotoDescription: "Добавлять снимок с камеры к уведомлениям, если он был сделан для события",
     quietHoursDnd: "Тихие часы (не беспокоить)",
     quietHoursDnd: "Тихие часы (не беспокоить)",
     quietStart: "Начало",
     quietStart: "Начало",
     quietEnd: "Окончание",
     quietEnd: "Окончание",
@@ -6196,6 +6199,7 @@ export default {
     nameRequired: "Укажите название",
     nameRequired: "Укажите название",
     fieldRequired: "Поле «{{field}}» обязательно",
     fieldRequired: "Поле «{{field}}» обязательно",
     haDataInvalid: 'Поле Data должно быть корректным JSON-объектом, напр. {"priority": "high", "ttl": 0}',
     haDataInvalid: 'Поле Data должно быть корректным JSON-объектом, напр. {"priority": "high", "ttl": 0}',
+    haServiceHelp: 'Необязательно — по умолчанию постоянное уведомление в интерфейсе Home Assistant. Укажите службу notify (например, notify.mobile_app_myphone), чтобы получать push-уведомления на телефон; к ним также автоматически прикрепляется снимок с камеры, если он есть и в Настройки → Сеть задан внешний URL.',
     phoneNumber: "Номер телефона",
     phoneNumber: "Номер телефона",
     apiKey: "Ключ API",
     apiKey: "Ключ API",
     serverUrl: "URL сервера",
     serverUrl: "URL сервера",
@@ -6245,6 +6249,10 @@ export default {
     enterTemplateContent: "Введите содержимое шаблона для предварительного просмотра",
     enterTemplateContent: "Введите содержимое шаблона для предварительного просмотра",
     titlePreview: "Заголовок:",
     titlePreview: "Заголовок:",
     bodyPreview: "Текст:",
     bodyPreview: "Текст:",
+    photoPreview: "Фото:",
+    photoPreviewNote: "Только пример — провайдеры, поддерживающие вложения (ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook), автоматически добавляют снимок с камеры, если он был сделан, независимо от текста выше.",
+    photoPreviewNoteWithEmail: "Только пример — автоматически прикрепляется для поддерживаемых провайдеров (ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook), если снимок был сделан. Для электронной почты добавьте {finish_photo_url} в текст выше, чтобы встроить фото в письмо.",
+    photoPreviewNoteEmailOnly: "Только пример — это всегда электронное письмо. Добавьте {finish_photo_url} в текст выше, чтобы встроить фото; без этого отправляется только текст.",
     resetToDefault: "Восстановить стандартный шаблон",
     resetToDefault: "Восстановить стандартный шаблон",
     titleRequired: "Укажите заголовок",
     titleRequired: "Укажите заголовок",
     bodyRequired: "Введите текст уведомления",
     bodyRequired: "Введите текст уведомления",

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

@@ -6432,6 +6432,7 @@ errors: {
     firstLayer: 'Första skikt',
     firstLayer: 'Första skikt',
     quiet: 'Tyst',
     quiet: 'Tyst',
     digest: 'Sammanfattning {{time}}',
     digest: 'Sammanfattning {{time}}',
+    noPhoto: 'Inget foto',
     // Event labels (expanded settings)
     // Event labels (expanded settings)
     printStarted: 'Utskrift startad',
     printStarted: 'Utskrift startad',
     plateNotEmpty: 'Platta inte tom',
     plateNotEmpty: 'Platta inte tom',
@@ -6520,6 +6521,8 @@ errors: {
     testConfiguration: 'Testa konfiguration',
     testConfiguration: 'Testa konfiguration',
     printerFilter: 'Skrivarfilter',
     printerFilter: 'Skrivarfilter',
     onlyFromPrinter: 'Skicka notiser endast för händelser från denna skrivare',
     onlyFromPrinter: 'Skicka notiser endast för händelser från denna skrivare',
+    attachPhotoLabel: 'Bifoga foto',
+    attachPhotoDescription: 'Skicka med en kamerabild i notiser när en har tagits för händelsen',
     quietHoursDnd: 'Tysta timmar (Stör ej)',
     quietHoursDnd: 'Tysta timmar (Stör ej)',
     quietStart: 'Start',
     quietStart: 'Start',
     quietEnd: 'Slut',
     quietEnd: 'Slut',
@@ -6549,6 +6552,7 @@ errors: {
     nameRequired: 'Namn krävs',
     nameRequired: 'Namn krävs',
     fieldRequired: '{{field}} krävs',
     fieldRequired: '{{field}} krävs',
     haDataInvalid: 'Data-fältet måste vara ett giltigt JSON-objekt, t.ex. {"priority": "high", "ttl": 0}',
     haDataInvalid: 'Data-fältet måste vara ett giltigt JSON-objekt, t.ex. {"priority": "high", "ttl": 0}',
+    haServiceHelp: 'Valfritt — som standard en beständig notis i Home Assistants gränssnitt. Ange en notify-tjänst (t.ex. notify.mobile_app_myphone) för att få push-notiser i telefonen; då bifogas också automatiskt en kamerabild när en finns och Extern URL är inställd under Inställningar → Nätverk.',
     // Config field labels
     // Config field labels
     phoneNumber: 'Telefonnummer',
     phoneNumber: 'Telefonnummer',
     apiKey: 'API-nyckel',
     apiKey: 'API-nyckel',
@@ -6600,6 +6604,10 @@ errors: {
     enterTemplateContent: 'Ange mallinnehåll för att se förhandsvisning',
     enterTemplateContent: 'Ange mallinnehåll för att se förhandsvisning',
     titlePreview: 'Titel:',
     titlePreview: 'Titel:',
     bodyPreview: 'Brödtext:',
     bodyPreview: 'Brödtext:',
+    photoPreview: 'Foto:',
+    photoPreviewNote: 'Endast ett exempel — tjänster som stöder bilagor (ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook) bifogar automatiskt en kamerabild när en har tagits, oavsett vad texten ovan säger.',
+    photoPreviewNoteWithEmail: 'Endast ett exempel — bifogas automatiskt för tjänster som stöds (ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook) när en bild har tagits. För e-post, lägg till {finish_photo_url} i texten ovan för att bädda in den.',
+    photoPreviewNoteEmailOnly: 'Endast ett exempel — detta är alltid ett e-postmeddelande. Lägg till {finish_photo_url} i texten ovan för att bädda in fotot; utan den skickas bara texten.',
     resetToDefault: 'Återställ till standard',
     resetToDefault: 'Återställ till standard',
     titleRequired: 'Titel krävs',
     titleRequired: 'Titel krävs',
     bodyRequired: 'Brödtext krävs',
     bodyRequired: 'Brödtext krävs',

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

@@ -6338,6 +6338,7 @@ export default {
     firstLayer: 'İlk Katman',
     firstLayer: 'İlk Katman',
     quiet: 'Sessiz',
     quiet: 'Sessiz',
     digest: 'Özet {{time}}',
     digest: 'Özet {{time}}',
+    noPhoto: 'Fotoğraf yok',
     printStarted: 'Baskı Başladı',
     printStarted: 'Baskı Başladı',
     plateNotEmpty: 'Plaka Boş Değil',
     plateNotEmpty: 'Plaka Boş Değil',
     plateNotEmptyDescription: 'Baskıdan önce nesneler algılandı',
     plateNotEmptyDescription: 'Baskıdan önce nesneler algılandı',
@@ -6419,6 +6420,8 @@ export default {
     testConfiguration: 'Yapılandırmayı Test Et',
     testConfiguration: 'Yapılandırmayı Test Et',
     printerFilter: 'Yazıcı Filtresi',
     printerFilter: 'Yazıcı Filtresi',
     onlyFromPrinter: 'Yalnızca bu yazıcının olayları için bildirim gönder',
     onlyFromPrinter: 'Yalnızca bu yazıcının olayları için bildirim gönder',
+    attachPhotoLabel: 'Fotoğraf ekle',
+    attachPhotoDescription: 'Olay için bir kamera görüntüsü alındıysa bildirimlere ekle',
     quietHoursDnd: 'Sessiz Saatler (Rahatsız Etme)',
     quietHoursDnd: 'Sessiz Saatler (Rahatsız Etme)',
     quietStart: 'Başlangıç',
     quietStart: 'Başlangıç',
     quietEnd: 'Bitiş',
     quietEnd: 'Bitiş',
@@ -6447,6 +6450,7 @@ export default {
     nameRequired: 'Ad gerekli',
     nameRequired: 'Ad gerekli',
     fieldRequired: '{{field}} gerekli',
     fieldRequired: '{{field}} gerekli',
     haDataInvalid: 'Data alanı geçerli bir JSON nesnesi olmalıdır, örn. {"priority": "high", "ttl": 0}',
     haDataInvalid: 'Data alanı geçerli bir JSON nesnesi olmalıdır, örn. {"priority": "high", "ttl": 0}',
+    haServiceHelp: 'İsteğe bağlı — varsayılan olarak Home Assistant arayüzünde kalıcı bir bildirim. Telefonunuza anlık bildirim almak için bir notify servisi (ör. notify.mobile_app_myphone) belirleyin; görüntü mevcutsa ve Ayarlar → Ağ bölümünde Harici URL ayarlıysa kamera görüntüsü de otomatik olarak eklenir.',
     phoneNumber: 'Telefon Numarası',
     phoneNumber: 'Telefon Numarası',
     apiKey: 'API Anahtarı',
     apiKey: 'API Anahtarı',
     serverUrl: 'Sunucu URL',
     serverUrl: 'Sunucu URL',
@@ -6496,6 +6500,10 @@ export default {
     enterTemplateContent: 'Önizlemeyi görmek için şablon içeriğini girin',
     enterTemplateContent: 'Önizlemeyi görmek için şablon içeriğini girin',
     titlePreview: 'Başlık:',
     titlePreview: 'Başlık:',
     bodyPreview: 'Gövde:',
     bodyPreview: 'Gövde:',
+    photoPreview: 'Fotoğraf:',
+    photoPreviewNote: 'Yalnızca örnek — ek destekleyen sağlayıcılar (ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook), bir kamera görüntüsü alındıysa yukarıdaki metinden bağımsız olarak bunu otomatik ekler.',
+    photoPreviewNoteWithEmail: 'Yalnızca örnek — bir görüntü alındıysa desteklenen sağlayıcılara (ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook) otomatik eklenir. E-postada satır içi göstermek için yukarıdaki metne {finish_photo_url} ekleyin.',
+    photoPreviewNoteEmailOnly: 'Yalnızca örnek — bu her zaman bir e-postadır. Fotoğrafı satır içi göstermek için yukarıdaki metne {finish_photo_url} ekleyin; eklenmezse yalnızca metin gönderilir.',
     resetToDefault: 'Varsayılana Sıfırla',
     resetToDefault: 'Varsayılana Sıfırla',
     titleRequired: 'Başlık gerekli',
     titleRequired: 'Başlık gerekli',
     bodyRequired: 'Gövde gerekli',
     bodyRequired: 'Gövde gerekli',

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

@@ -6424,6 +6424,7 @@ export default {
     firstLayer: "Перший шар",
     firstLayer: "Перший шар",
     quiet: "Тихо",
     quiet: "Тихо",
     digest: "Дайджест {{time}}",
     digest: "Дайджест {{time}}",
+    noPhoto: "Без фото",
     // Event labels (expanded settings)
     // Event labels (expanded settings)
     printStarted: "Друк розпочато",
     printStarted: "Друк розпочато",
     plateNotEmpty: "Друкарська пластина не порожня",
     plateNotEmpty: "Друкарська пластина не порожня",
@@ -6512,6 +6513,8 @@ export default {
     testConfiguration: "Перевірити конфігурацію",
     testConfiguration: "Перевірити конфігурацію",
     printerFilter: "Фільтр принтера",
     printerFilter: "Фільтр принтера",
     onlyFromPrinter: "Надсилати лише сповіщення про події з цього принтера",
     onlyFromPrinter: "Надсилати лише сповіщення про події з цього принтера",
+    attachPhotoLabel: "Додавати фото",
+    attachPhotoDescription: "Додавати знімок з камери до сповіщень, якщо його було зроблено для події",
     quietHoursDnd: "Тихі години (не турбувати)",
     quietHoursDnd: "Тихі години (не турбувати)",
     quietStart: "Початок",
     quietStart: "Початок",
     quietEnd: "Кінець",
     quietEnd: "Кінець",
@@ -6541,6 +6544,7 @@ export default {
     nameRequired: "Необхідно вказати ім’я",
     nameRequired: "Необхідно вказати ім’я",
     fieldRequired: "{{field}} — обов’язкове поле.",
     fieldRequired: "{{field}} — обов’язкове поле.",
     haDataInvalid: "Поле «Дані» має містити дійсний об’єкт JSON, наприклад {\"priority\": \"high\", \"ttl\": 0}",
     haDataInvalid: "Поле «Дані» має містити дійсний об’єкт JSON, наприклад {\"priority\": \"high\", \"ttl\": 0}",
+    haServiceHelp: "Необов'язково — за замовчуванням постійне сповіщення в інтерфейсі Home Assistant. Вкажіть службу notify (наприклад, notify.mobile_app_myphone), щоб отримувати push-сповіщення на телефон; до них також автоматично додається знімок з камери, якщо він є і в Налаштування → Мережа задано зовнішній URL.",
     // Config field labels
     // Config field labels
     phoneNumber: "Номер телефону",
     phoneNumber: "Номер телефону",
     apiKey: "Ключ API",
     apiKey: "Ключ API",
@@ -6592,6 +6596,10 @@ export default {
     enterTemplateContent: "Введіть вміст шаблону, щоб побачити попередній перегляд",
     enterTemplateContent: "Введіть вміст шаблону, щоб побачити попередній перегляд",
     titlePreview: "Назва:",
     titlePreview: "Назва:",
     bodyPreview: "Текст:",
     bodyPreview: "Текст:",
+    photoPreview: "Фото:",
+    photoPreviewNote: "Лише приклад — провайдери, що підтримують вкладення (ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook), автоматично додають знімок з камери, якщо його було зроблено, незалежно від тексту вище.",
+    photoPreviewNoteWithEmail: "Лише приклад — автоматично додається для підтримуваних провайдерів (ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook), якщо знімок було зроблено. Для електронної пошти додайте {finish_photo_url} у текст вище, щоб вбудувати фото в лист.",
+    photoPreviewNoteEmailOnly: "Лише приклад — це завжди електронний лист. Додайте {finish_photo_url} у текст вище, щоб вбудувати фото; без цього надсилається лише текст.",
     resetToDefault: "Відновити типові значення",
     resetToDefault: "Відновити типові значення",
     titleRequired: "Необхідно вказати назву",
     titleRequired: "Необхідно вказати назву",
     bodyRequired: "Текст є обов’язковим",
     bodyRequired: "Текст є обов’язковим",

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

@@ -6369,6 +6369,7 @@ export default {
     firstLayer: '首层完成',
     firstLayer: '首层完成',
     quiet: '免打扰',
     quiet: '免打扰',
     digest: '摘要 {{time}}',
     digest: '摘要 {{time}}',
+    noPhoto: '无照片',
     // Event labels (expanded settings)
     // Event labels (expanded settings)
     printStarted: '打印已开始',
     printStarted: '打印已开始',
     plateNotEmpty: '热床非空',
     plateNotEmpty: '热床非空',
@@ -6457,6 +6458,8 @@ export default {
     testConfiguration: '测试配置',
     testConfiguration: '测试配置',
     printerFilter: '打印机筛选',
     printerFilter: '打印机筛选',
     onlyFromPrinter: '仅发送来自此打印机的事件通知',
     onlyFromPrinter: '仅发送来自此打印机的事件通知',
+    attachPhotoLabel: '附加照片',
+    attachPhotoDescription: '若事件拍摄了摄像头快照,则随通知一并发送',
     quietHoursDnd: '免打扰时段',
     quietHoursDnd: '免打扰时段',
     quietStart: '开始',
     quietStart: '开始',
     quietEnd: '结束',
     quietEnd: '结束',
@@ -6486,6 +6489,7 @@ export default {
     nameRequired: '名称为必填项',
     nameRequired: '名称为必填项',
     fieldRequired: '{{field}}为必填项',
     fieldRequired: '{{field}}为必填项',
     haDataInvalid: 'Data 字段必须是有效的 JSON 对象,例如 {"priority": "high", "ttl": 0}',
     haDataInvalid: 'Data 字段必须是有效的 JSON 对象,例如 {"priority": "high", "ttl": 0}',
+    haServiceHelp: '可选 — 默认在 Home Assistant 界面中显示持久通知。设置 notify 服务(例如 notify.mobile_app_myphone)即可在手机上接收推送通知;若有快照且已在 设置 → 网络 中设置外部URL,还会自动附加摄像头快照。',
     // Config field labels
     // Config field labels
     phoneNumber: '电话号码',
     phoneNumber: '电话号码',
     apiKey: 'API 密钥',
     apiKey: 'API 密钥',
@@ -6537,6 +6541,10 @@ export default {
     enterTemplateContent: '输入模板内容以查看预览',
     enterTemplateContent: '输入模板内容以查看预览',
     titlePreview: '标题:',
     titlePreview: '标题:',
     bodyPreview: '正文:',
     bodyPreview: '正文:',
+    photoPreview: '照片:',
+    photoPreviewNote: '仅为示例 — 支持附件的服务(ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook)在拍摄了快照时会自动附加摄像头快照,与上方正文内容无关。',
+    photoPreviewNoteWithEmail: '仅为示例 — 拍摄了快照时会自动附加到支持的服务(ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook)。对于电子邮件,请在上方正文中添加 {finish_photo_url} 以内嵌照片。',
+    photoPreviewNoteEmailOnly: '仅为示例 — 此通知始终为电子邮件。在上方正文中添加 {finish_photo_url} 即可内嵌照片;否则仅发送文本。',
     resetToDefault: '恢复默认',
     resetToDefault: '恢复默认',
     titleRequired: '标题为必填项',
     titleRequired: '标题为必填项',
     bodyRequired: '正文为必填项',
     bodyRequired: '正文为必填项',

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

@@ -6369,6 +6369,7 @@ export default {
     firstLayer: '首層完成',
     firstLayer: '首層完成',
     quiet: '免打擾',
     quiet: '免打擾',
     digest: '摘要 {{time}}',
     digest: '摘要 {{time}}',
+    noPhoto: '無照片',
     // Event labels (expanded settings)
     // Event labels (expanded settings)
     printStarted: '列印已開始',
     printStarted: '列印已開始',
     plateNotEmpty: '熱床非空',
     plateNotEmpty: '熱床非空',
@@ -6457,6 +6458,8 @@ export default {
     testConfiguration: '測試設定',
     testConfiguration: '測試設定',
     printerFilter: '印表機篩選',
     printerFilter: '印表機篩選',
     onlyFromPrinter: '僅傳送來自此印表機的事件通知',
     onlyFromPrinter: '僅傳送來自此印表機的事件通知',
+    attachPhotoLabel: '附加照片',
+    attachPhotoDescription: '若事件拍攝了攝影機快照,則隨通知一併傳送',
     quietHoursDnd: '免打擾時段',
     quietHoursDnd: '免打擾時段',
     quietStart: '開始',
     quietStart: '開始',
     quietEnd: '結束',
     quietEnd: '結束',
@@ -6486,6 +6489,7 @@ export default {
     nameRequired: '名稱為必填項',
     nameRequired: '名稱為必填項',
     fieldRequired: '{{field}}為必填項',
     fieldRequired: '{{field}}為必填項',
     haDataInvalid: 'Data 欄位必須是有效的 JSON 物件,例如 {"priority": "high", "ttl": 0}',
     haDataInvalid: 'Data 欄位必須是有效的 JSON 物件,例如 {"priority": "high", "ttl": 0}',
+    haServiceHelp: '選填 — 預設在 Home Assistant 介面中顯示持續通知。設定 notify 服務(例如 notify.mobile_app_myphone)即可在手機上接收推播通知;若有快照且已在 設定 → 網路 中設定外部URL,還會自動附加攝影機快照。',
     // Config field labels
     // Config field labels
     phoneNumber: '電話號碼',
     phoneNumber: '電話號碼',
     apiKey: 'API 金鑰',
     apiKey: 'API 金鑰',
@@ -6537,6 +6541,10 @@ export default {
     enterTemplateContent: '輸入範本內容以檢視預覽',
     enterTemplateContent: '輸入範本內容以檢視預覽',
     titlePreview: '標題:',
     titlePreview: '標題:',
     bodyPreview: '正文:',
     bodyPreview: '正文:',
+    photoPreview: '照片:',
+    photoPreviewNote: '僅為範例 — 支援附件的服務(ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook)在拍攝了快照時會自動附加攝影機快照,與上方內文內容無關。',
+    photoPreviewNoteWithEmail: '僅為範例 — 拍攝了快照時會自動附加到支援的服務(ntfy, Pushover, Telegram, Discord, Home Assistant, Bark, Slack webhook)。若為電子郵件,請在上方內文中加入 {finish_photo_url} 以內嵌照片。',
+    photoPreviewNoteEmailOnly: '僅為範例 — 此通知一律為電子郵件。在上方內文中加入 {finish_photo_url} 即可內嵌照片;否則僅傳送文字。',
     resetToDefault: '恢復預設',
     resetToDefault: '恢復預設',
     titleRequired: '標題為必填項',
     titleRequired: '標題為必填項',
     bodyRequired: '正文為必填項',
     bodyRequired: '正文為必填項',