Browse Source

Add camera image attachments to Pushover notifications

SBCrumb 3 months ago
parent
commit
6aa7a560d8
3 changed files with 219 additions and 58 deletions
  1. 107 3
      backend/app/main.py
  2. 62 44
      backend/app/services/camera.py
  3. 50 11
      backend/app/services/notification_service.py

+ 107 - 3
backend/app/main.py

@@ -456,8 +456,19 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
                     # remaining_time is in minutes, convert to seconds for notification
                     # remaining_time is in minutes, convert to seconds for notification
                     remaining_time_seconds = state.remaining_time * 60 if state.remaining_time else None
                     remaining_time_seconds = state.remaining_time * 60 if state.remaining_time else None
 
 
+                    # Capture camera snapshot for notification image attachment
+                    image_data = await _capture_snapshot_for_notification(
+                        printer_id, printer, logging.getLogger(__name__)
+                    )
+
                     await notification_service.on_print_progress(
                     await notification_service.on_print_progress(
-                        printer_id, printer_name, filename, current_milestone, db, remaining_time_seconds
+                        printer_id,
+                        printer_name,
+                        filename,
+                        current_milestone,
+                        db,
+                        remaining_time_seconds,
+                        image_data=image_data,
                     )
                     )
             except Exception as e:
             except Exception as e:
                 logging.getLogger(__name__).warning(f"Progress milestone notification failed: {e}")
                 logging.getLogger(__name__).warning(f"Progress milestone notification failed: {e}")
@@ -499,6 +510,11 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
 
 
                     from backend.app.services.hms_errors import get_error_description
                     from backend.app.services.hms_errors import get_error_description
 
 
+                    # Capture camera snapshot once for all error notifications
+                    error_image_data = await _capture_snapshot_for_notification(
+                        printer_id, printer, logging.getLogger(__name__)
+                    )
+
                     for error in new_errors:
                     for error in new_errors:
                         module_name = module_names.get(error.module, f"Module 0x{error.module:02X}")
                         module_name = module_names.get(error.module, f"Module 0x{error.module:02X}")
                         # Build short code like "0700_8010"
                         # Build short code like "0700_8010"
@@ -511,7 +527,7 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
                         error_detail = description if description else f"Error code: {short_code}"
                         error_detail = description if description else f"Error code: {short_code}"
 
 
                         await notification_service.on_printer_error(
                         await notification_service.on_printer_error(
-                            printer_id, printer_name, error_type, db, error_detail
+                            printer_id, printer_name, error_type, db, error_detail, image_data=error_image_data
                         )
                         )
 
 
                     logging.getLogger(__name__).info(
                     logging.getLogger(__name__).info(
@@ -639,6 +655,63 @@ async def on_ams_change(printer_id: int, ams_data: list):
         logging.getLogger(__name__).warning(f"Spoolman AMS sync failed: {e}")
         logging.getLogger(__name__).warning(f"Spoolman AMS sync failed: {e}")
 
 
 
 
+async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -> bytes | None:
+    """Capture a camera snapshot for notification image attachment.
+
+    Returns JPEG bytes (max 2.5MB) or None if capture fails or is unavailable.
+    Uses: external camera > buffered frame > fresh capture.
+    """
+    if not printer:
+        return None
+
+    try:
+        from backend.app.api.routes.settings import get_setting
+
+        async with async_session() as db:
+            capture_enabled = await get_setting(db, "capture_finish_photo")
+
+        if capture_enabled is not None and capture_enabled.lower() != "true":
+            return None
+
+        # Try external camera first
+        if printer.external_camera_enabled and printer.external_camera_url:
+            logger.info(f"[SNAPSHOT] Capturing from external camera for printer {printer_id}")
+            from backend.app.services.external_camera import capture_frame
+
+            frame_data = await capture_frame(printer.external_camera_url, printer.external_camera_type or "mjpeg")
+            if frame_data and len(frame_data) <= 2_500_000:
+                logger.info(f"[SNAPSHOT] External camera frame: {len(frame_data)} bytes")
+                return frame_data
+
+        # Try buffered frame from active stream
+        from backend.app.api.routes.camera import _active_chamber_streams, _active_streams, get_buffered_frame
+
+        active_for_printer = [k for k in _active_streams if k.startswith(f"{printer_id}-")]
+        active_chamber = [k for k in _active_chamber_streams if k.startswith(f"{printer_id}-")]
+        buffered_frame = get_buffered_frame(printer_id)
+
+        if (active_for_printer or active_chamber) and buffered_frame:
+            logger.info(f"[SNAPSHOT] Using buffered frame for printer {printer_id}: {len(buffered_frame)} bytes")
+            if len(buffered_frame) <= 2_500_000:
+                return buffered_frame
+
+        # Fresh capture from printer camera
+        logger.info(f"[SNAPSHOT] Capturing fresh frame for printer {printer_id}")
+        from backend.app.services.camera import capture_camera_frame_bytes
+
+        frame_data = await capture_camera_frame_bytes(
+            printer.ip_address, printer.access_code, printer.model, timeout=15
+        )
+        if frame_data and len(frame_data) <= 2_500_000:
+            logger.info(f"[SNAPSHOT] Fresh camera frame: {len(frame_data)} bytes")
+            return frame_data
+
+    except Exception as e:
+        logger.warning(f"[SNAPSHOT] Failed to capture snapshot for printer {printer_id}: {e}")
+
+    return None
+
+
 async def _send_print_start_notification(
 async def _send_print_start_notification(
     printer_id: int,
     printer_id: int,
     data: dict,
     data: dict,
@@ -658,6 +731,14 @@ async def _send_print_start_notification(
             result = await db.execute(select(Printer).where(Printer.id == printer_id))
             result = await db.execute(select(Printer).where(Printer.id == printer_id))
             printer = result.scalar_one_or_none()
             printer = result.scalar_one_or_none()
             printer_name = printer.name if printer else f"Printer {printer_id}"
             printer_name = printer.name if printer else f"Printer {printer_id}"
+
+            # Capture camera snapshot for notification image attachment
+            image_data = await _capture_snapshot_for_notification(printer_id, printer, logger)
+            if image_data:
+                if archive_data is None:
+                    archive_data = {}
+                archive_data["image_data"] = image_data
+
             await notification_service.on_print_start(printer_id, printer_name, data, db, archive_data=archive_data)
             await notification_service.on_print_start(printer_id, printer_name, data, db, archive_data=archive_data)
     except Exception as e:
     except Exception as e:
         logger.warning(f"Notification on_print_start failed: {e}")
         logger.warning(f"Notification on_print_start failed: {e}")
@@ -1851,7 +1932,7 @@ async def on_print_complete(printer_id: int, data: dict):
                             "actual_filament_grams": archive.filament_used_grams,
                             "actual_filament_grams": archive.filament_used_grams,
                             "failure_reason": archive.failure_reason,
                             "failure_reason": archive.failure_reason,
                         }
                         }
-                        # Add finish photo URL if available
+                        # Add finish photo URL and image bytes if available
                         if finish_photo_filename:
                         if finish_photo_filename:
                             from backend.app.api.routes.settings import get_setting
                             from backend.app.api.routes.settings import get_setting
 
 
@@ -1867,6 +1948,29 @@ async def on_print_complete(printer_id: int, data: dict):
                                     f"/api/v1/archives/{archive_id}/photos/{finish_photo_filename}"
                                     f"/api/v1/archives/{archive_id}/photos/{finish_photo_filename}"
                                 )
                                 )
 
 
+                            # Read finish photo bytes for image attachment (e.g. Pushover)
+                            try:
+                                from pathlib import Path
+
+                                photo_path = (
+                                    app_settings.base_dir
+                                    / Path(archive.file_path).parent
+                                    / "photos"
+                                    / finish_photo_filename
+                                )
+                                if photo_path.exists():
+                                    photo_bytes = await asyncio.to_thread(photo_path.read_bytes)
+                                    if len(photo_bytes) <= 2_500_000:
+                                        archive_data["image_data"] = photo_bytes
+                                        logger.info(f"[NOTIFY-BG] Loaded finish photo bytes: {len(photo_bytes)} bytes")
+                                    else:
+                                        logger.warning(
+                                            f"[NOTIFY-BG] Finish photo too large for attachment: "
+                                            f"{len(photo_bytes)} bytes"
+                                        )
+                            except Exception as e:
+                                logger.warning(f"[NOTIFY-BG] Failed to read finish photo bytes: {e}")
+
                 await notification_service.on_print_complete(
                 await notification_service.on_print_complete(
                     printer_id, printer_name, print_status, data, db, archive_data=archive_data
                     printer_id, printer_name, print_status, data, db, archive_data=archive_data
                 )
                 )

+ 62 - 44
backend/app/services/camera.py

@@ -301,11 +301,10 @@ async def capture_camera_frame(
     output_path: Path,
     output_path: Path,
     timeout: int = 30,
     timeout: int = 30,
 ) -> bool:
 ) -> bool:
-    """Capture a single frame from the printer's camera stream.
+    """Capture a single frame from the printer's camera stream and save to disk.
 
 
-    Uses the appropriate protocol based on printer model:
-    - A1/P1: Chamber image protocol (port 6000)
-    - X1/H2/P2: RTSP via ffmpeg (port 322)
+    Uses capture_camera_frame_bytes() internally for protocol selection,
+    then writes the result to the specified output path.
 
 
     Args:
     Args:
         ip_address: Printer IP address
         ip_address: Printer IP address
@@ -317,36 +316,57 @@ async def capture_camera_frame(
     Returns:
     Returns:
         True if capture was successful, False otherwise
         True if capture was successful, False otherwise
     """
     """
-    # Ensure output directory exists
     output_path.parent.mkdir(parents=True, exist_ok=True)
     output_path.parent.mkdir(parents=True, exist_ok=True)
 
 
-    # Use chamber image protocol for A1/P1 models
+    jpeg_data = await capture_camera_frame_bytes(ip_address, access_code, model, timeout)
+    if jpeg_data:
+        try:
+            with open(output_path, "wb") as f:
+                f.write(jpeg_data)
+            logger.info(f"Saved camera frame to: {output_path}")
+            return True
+        except Exception as e:
+            logger.error(f"Failed to write camera frame: {e}")
+            return False
+    return False
+
+
+async def capture_camera_frame_bytes(
+    ip_address: str,
+    access_code: str,
+    model: str | None,
+    timeout: int = 15,
+) -> bytes | None:
+    """Capture a single frame and return as JPEG bytes (no disk write).
+
+    Uses the same protocol selection as capture_camera_frame but returns
+    bytes directly instead of writing to disk.
+
+    Args:
+        ip_address: Printer IP address
+        access_code: Printer access code
+        model: Printer model (X1, H2D, P1, A1, etc.)
+        timeout: Timeout in seconds for the capture operation
+
+    Returns:
+        JPEG bytes if capture was successful, None otherwise
+    """
+    # Chamber image models: A1/P1 - returns bytes directly
     if is_chamber_image_model(model):
     if is_chamber_image_model(model):
-        logger.info(f"Capturing camera frame from {ip_address} using chamber image protocol (model: {model})")
-        jpeg_data = await read_chamber_image_frame(ip_address, access_code, timeout=float(timeout))
-        if jpeg_data:
-            try:
-                with open(output_path, "wb") as f:
-                    f.write(jpeg_data)
-                logger.info(f"Successfully captured camera frame: {output_path}")
-                return True
-            except Exception as e:
-                logger.error(f"Failed to write camera frame: {e}")
-                return False
-        return False
-
-    # Use RTSP/ffmpeg for X1/H2/P2 models
+        logger.info(f"Capturing camera frame bytes from {ip_address} using chamber image protocol (model: {model})")
+        return await read_chamber_image_frame(ip_address, access_code, timeout=float(timeout))
+
+    # RTSP models: X1/H2/P2 - use ffmpeg piping to stdout
     camera_url = build_camera_url(ip_address, access_code, model)
     camera_url = build_camera_url(ip_address, access_code, model)
 
 
     ffmpeg = get_ffmpeg_path()
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
     if not ffmpeg:
-        logger.error("ffmpeg not found. Please install ffmpeg to enable camera capture.")
-        return False
+        logger.error("ffmpeg not found for camera frame capture")
+        return None
 
 
-    # ffmpeg command to capture a single frame from RTSPS stream
     cmd = [
     cmd = [
         ffmpeg,
         ffmpeg,
-        "-y",  # Overwrite output
+        "-y",
         "-rtsp_transport",
         "-rtsp_transport",
         "tcp",
         "tcp",
         "-rtsp_flags",
         "-rtsp_flags",
@@ -355,14 +375,16 @@ async def capture_camera_frame(
         camera_url,
         camera_url,
         "-frames:v",
         "-frames:v",
         "1",
         "1",
-        "-update",
-        "1",
+        "-f",
+        "image2pipe",
+        "-vcodec",
+        "mjpeg",
         "-q:v",
         "-q:v",
         "2",
         "2",
-        str(output_path),
+        "-",
     ]
     ]
 
 
-    logger.info(f"Capturing camera frame from {ip_address} using RTSP (model: {model})")
+    logger.info(f"Capturing camera frame bytes from {ip_address} using RTSP (model: {model})")
 
 
     try:
     try:
         process = await asyncio.create_subprocess_exec(
         process = await asyncio.create_subprocess_exec(
@@ -376,27 +398,23 @@ async def capture_camera_frame(
         except TimeoutError:
         except TimeoutError:
             process.kill()
             process.kill()
             await process.wait()
             await process.wait()
-            logger.error(f"Camera capture timed out after {timeout}s")
-            return False
-
-        if process.returncode != 0:
-            stderr_text = stderr.decode() if stderr else "Unknown error"
-            logger.error(f"ffmpeg failed with code {process.returncode}: {stderr_text}")
-            return False
+            logger.error(f"Camera frame bytes capture timed out after {timeout}s")
+            return None
 
 
-        if output_path.exists() and output_path.stat().st_size > 0:
-            logger.info(f"Successfully captured camera frame: {output_path}")
-            return True
+        if process.returncode == 0 and stdout and len(stdout) >= 100:
+            logger.info(f"Successfully captured camera frame bytes: {len(stdout)} bytes")
+            return stdout
         else:
         else:
-            logger.error("Camera capture produced no output file")
-            return False
+            stderr_text = stderr.decode() if stderr else "Unknown error"
+            logger.error(f"ffmpeg frame bytes capture failed (code {process.returncode}): {stderr_text[:200]}")
+            return None
 
 
     except FileNotFoundError:
     except FileNotFoundError:
-        logger.error("ffmpeg not found. Please install ffmpeg to enable camera capture.")
-        return False
+        logger.error("ffmpeg not found for camera frame capture")
+        return None
     except Exception as e:
     except Exception as e:
-        logger.exception(f"Camera capture failed: {e}")
-        return False
+        logger.exception(f"Camera frame bytes capture failed: {e}")
+        return None
 
 
 
 
 async def capture_finish_photo(
 async def capture_finish_photo(

+ 50 - 11
backend/app/services/notification_service.py

@@ -211,8 +211,17 @@ 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_pushover(self, config: dict, title: str, message: str) -> tuple[bool, str]:
-        """Send notification via Pushover."""
+    async def _send_pushover(
+        self, config: dict, title: str, message: str, image_data: bytes | None = None
+    ) -> tuple[bool, str]:
+        """Send notification via Pushover.
+
+        Args:
+            config: Provider configuration with user_key, app_token, priority
+            title: Notification title
+            message: Notification body
+            image_data: Optional JPEG image bytes to attach (max 2.5MB)
+        """
         user_key = config.get("user_key", "").strip()
         user_key = config.get("user_key", "").strip()
         app_token = config.get("app_token", "").strip()
         app_token = config.get("app_token", "").strip()
         priority = config.get("priority", 0)
         priority = config.get("priority", 0)
@@ -230,7 +239,13 @@ class NotificationService:
         }
         }
 
 
         client = await self._get_client()
         client = await self._get_client()
-        response = await client.post(url, data=data)
+
+        if image_data:
+            # Pushover supports image attachments via multipart form-data
+            files = {"attachment": ("photo.jpg", image_data, "image/jpeg")}
+            response = await client.post(url, data=data, files=files)
+        else:
+            response = await client.post(url, data=data)
 
 
         if response.status_code == 200:
         if response.status_code == 200:
             return True, "Message sent successfully"
             return True, "Message sent successfully"
@@ -407,7 +422,12 @@ class NotificationService:
             return False, f"Webhook error: {str(e)}"
             return False, f"Webhook error: {str(e)}"
 
 
     async def _send_to_provider(
     async def _send_to_provider(
-        self, provider: NotificationProvider, title: str, message: str, db: AsyncSession | None = None
+        self,
+        provider: NotificationProvider,
+        title: str,
+        message: str,
+        db: AsyncSession | None = None,
+        image_data: bytes | None = None,
     ) -> tuple[bool, str]:
     ) -> tuple[bool, str]:
         """Send notification to a specific provider."""
         """Send notification to a specific provider."""
         # Check quiet hours
         # Check quiet hours
@@ -423,7 +443,7 @@ class NotificationService:
             elif provider.provider_type == "ntfy":
             elif provider.provider_type == "ntfy":
                 return await self._send_ntfy(config, title, message)
                 return await self._send_ntfy(config, title, message)
             elif provider.provider_type == "pushover":
             elif provider.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.provider_type == "telegram":
             elif provider.provider_type == "telegram":
                 return await self._send_telegram(config, f"*{title}*\n{message}")
                 return await self._send_telegram(config, f"*{title}*\n{message}")
             elif provider.provider_type == "email":
             elif provider.provider_type == "email":
@@ -513,6 +533,7 @@ class NotificationService:
         printer_id: int | None = None,
         printer_id: int | None = None,
         printer_name: str | None = None,
         printer_name: str | None = None,
         force_immediate: bool = False,
         force_immediate: bool = False,
+        image_data: bytes | None = None,
     ):
     ):
         """Send notification to multiple providers and log the results.
         """Send notification to multiple providers and log the results.
 
 
@@ -522,7 +543,7 @@ class NotificationService:
         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(provider, title, message, db)
+                success, error = await self._send_to_provider(provider, title, message, db, image_data=image_data)
 
 
                 # 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)
                 if provider.daily_digest_enabled and provider.daily_digest_time:
                 if provider.daily_digest_enabled and provider.daily_digest_time:
@@ -629,9 +650,16 @@ class NotificationService:
             "estimated_time": time_str,
             "estimated_time": time_str,
         }
         }
 
 
+        # Extract image data for providers that support attachments (e.g. Pushover)
+        image_data = None
+        if archive_data:
+            image_data = archive_data.get("image_data")
+
         logger.info(f"Found {len(providers)} providers for print_start: {[p.name for p in providers]}")
         logger.info(f"Found {len(providers)} providers for print_start: {[p.name for p in providers]}")
         title, message = await self._build_message_from_template(db, "print_start", variables)
         title, message = await self._build_message_from_template(db, "print_start", variables)
-        await self._send_to_providers(providers, title, message, db, "print_start", printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, "print_start", printer_id, printer_name, image_data=image_data
+        )
 
 
     async def on_print_complete(
     async def on_print_complete(
         self,
         self,
@@ -690,11 +718,16 @@ class NotificationService:
             if archive_data.get("finish_photo_url"):
             if archive_data.get("finish_photo_url"):
                 variables["finish_photo_url"] = archive_data["finish_photo_url"]
                 variables["finish_photo_url"] = archive_data["finish_photo_url"]
 
 
-        logger.info(f"on_print_complete variables: {variables}, archive_data: {archive_data}")
+        # Extract image data for providers that support attachments (e.g. Pushover)
+        image_data = None
+        if archive_data:
+            image_data = archive_data.get("image_data")
 
 
         logger.info(f"Found {len(providers)} providers for {event_field}: {[p.name for p in providers]}")
         logger.info(f"Found {len(providers)} providers for {event_field}: {[p.name for p in providers]}")
         title, message = await self._build_message_from_template(db, event_type, variables)
         title, message = await self._build_message_from_template(db, event_type, variables)
-        await self._send_to_providers(providers, title, message, db, event_type, printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, event_type, printer_id, printer_name, image_data=image_data
+        )
 
 
     async def on_print_progress(
     async def on_print_progress(
         self,
         self,
@@ -704,6 +737,7 @@ class NotificationService:
         progress: int,
         progress: int,
         db: AsyncSession,
         db: AsyncSession,
         remaining_time: int | None = None,
         remaining_time: int | None = None,
+        image_data: bytes | None = None,
     ):
     ):
         """Handle print progress milestone (25%, 50%, 75%)."""
         """Handle print progress milestone (25%, 50%, 75%)."""
         providers = await self._get_providers_for_event(db, "on_print_progress", printer_id)
         providers = await self._get_providers_for_event(db, "on_print_progress", printer_id)
@@ -718,7 +752,9 @@ class NotificationService:
         }
         }
 
 
         title, message = await self._build_message_from_template(db, "print_progress", variables)
         title, message = await self._build_message_from_template(db, "print_progress", variables)
-        await self._send_to_providers(providers, title, message, db, "print_progress", printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, "print_progress", printer_id, printer_name, image_data=image_data
+        )
 
 
     async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
     async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
         """Handle printer offline event."""
         """Handle printer offline event."""
@@ -738,6 +774,7 @@ class NotificationService:
         error_type: str,
         error_type: str,
         db: AsyncSession,
         db: AsyncSession,
         error_detail: str | None = None,
         error_detail: str | None = None,
+        image_data: bytes | None = None,
     ):
     ):
         """Handle printer error event (AMS issues, etc.)."""
         """Handle printer error event (AMS issues, etc.)."""
         providers = await self._get_providers_for_event(db, "on_printer_error", printer_id)
         providers = await self._get_providers_for_event(db, "on_printer_error", printer_id)
@@ -751,7 +788,9 @@ class NotificationService:
         }
         }
 
 
         title, message = await self._build_message_from_template(db, "printer_error", variables)
         title, message = await self._build_message_from_template(db, "printer_error", variables)
-        await self._send_to_providers(providers, title, message, db, "printer_error", printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, "printer_error", printer_id, printer_name, image_data=image_data
+        )
 
 
     async def on_plate_not_empty(
     async def on_plate_not_empty(
         self,
         self,