From 6aa7a560d81245077ed82f11a39a1cb4a661d6f6 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Tue, 3 Feb 2026 13:13:09 -0500 Subject: [PATCH] Add camera image attachments to Pushover notifications --- backend/app/main.py | 110 ++++++++++++++++++- backend/app/services/camera.py | 106 ++++++++++-------- backend/app/services/notification_service.py | 61 ++++++++-- 3 files changed, 219 insertions(+), 58 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 9102b774f..a714d2274 100644 --- a/backend/app/main.py +++ b/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_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( - 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: 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 + # 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: module_name = module_names.get(error.module, f"Module 0x{error.module:02X}") # 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}" 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( @@ -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}") +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( printer_id: int, data: dict, @@ -658,6 +731,14 @@ async def _send_print_start_notification( result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() 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) except Exception as 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, "failure_reason": archive.failure_reason, } - # Add finish photo URL if available + # Add finish photo URL and image bytes if available if finish_photo_filename: 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}" ) + # 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( printer_id, printer_name, print_status, data, db, archive_data=archive_data ) diff --git a/backend/app/services/camera.py b/backend/app/services/camera.py index a146d270a..5c4f4cc7e 100644 --- a/backend/app/services/camera.py +++ b/backend/app/services/camera.py @@ -301,11 +301,10 @@ async def capture_camera_frame( output_path: Path, timeout: int = 30, ) -> 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: ip_address: Printer IP address @@ -317,36 +316,57 @@ async def capture_camera_frame( Returns: True if capture was successful, False otherwise """ - # Ensure output directory exists output_path.parent.mkdir(parents=True, exist_ok=True) - # Use chamber image protocol for A1/P1 models - 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 + 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 - # Use RTSP/ffmpeg for X1/H2/P2 models + +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): + 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) ffmpeg = get_ffmpeg_path() 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 = [ ffmpeg, - "-y", # Overwrite output + "-y", "-rtsp_transport", "tcp", "-rtsp_flags", @@ -355,14 +375,16 @@ async def capture_camera_frame( camera_url, "-frames:v", "1", - "-update", - "1", + "-f", + "image2pipe", + "-vcodec", + "mjpeg", "-q:v", "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: process = await asyncio.create_subprocess_exec( @@ -376,27 +398,23 @@ async def capture_camera_frame( except TimeoutError: process.kill() await process.wait() - logger.error(f"Camera capture timed out after {timeout}s") - return False + logger.error(f"Camera frame bytes capture timed out after {timeout}s") + return None - 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 - - 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: - 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: - 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: - 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( diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 92a51cd14..cf198c07e 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -211,8 +211,17 @@ class NotificationService: else: 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() app_token = config.get("app_token", "").strip() priority = config.get("priority", 0) @@ -230,7 +239,13 @@ class NotificationService: } 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: return True, "Message sent successfully" @@ -407,7 +422,12 @@ class NotificationService: return False, f"Webhook error: {str(e)}" 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]: """Send notification to a specific provider.""" # Check quiet hours @@ -423,7 +443,7 @@ class NotificationService: elif provider.provider_type == "ntfy": return await self._send_ntfy(config, title, message) 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": return await self._send_telegram(config, f"*{title}*\n{message}") elif provider.provider_type == "email": @@ -513,6 +533,7 @@ class NotificationService: printer_id: int | None = None, printer_name: str | None = None, force_immediate: bool = False, + image_data: bytes | None = None, ): """Send notification to multiple providers and log the results. @@ -522,7 +543,7 @@ class NotificationService: for provider in providers: try: # 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) if provider.daily_digest_enabled and provider.daily_digest_time: @@ -629,9 +650,16 @@ class NotificationService: "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]}") 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( self, @@ -690,11 +718,16 @@ class NotificationService: if archive_data.get("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]}") 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( self, @@ -704,6 +737,7 @@ class NotificationService: progress: int, db: AsyncSession, remaining_time: int | None = None, + image_data: bytes | None = None, ): """Handle print progress milestone (25%, 50%, 75%).""" 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) - 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): """Handle printer offline event.""" @@ -738,6 +774,7 @@ class NotificationService: error_type: str, db: AsyncSession, error_detail: str | None = None, + image_data: bytes | None = None, ): """Handle printer error event (AMS issues, etc.).""" 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) - 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( self,