Merge pull request #253 from sbcrumb/feature/pushover-image-attachments

Add camera image attachments to Pushover notifications
This commit is contained in:
MartinNYHC
2026-02-04 16:27:20 +01:00
committed by GitHub
3 changed files with 219 additions and 58 deletions
+107 -3
View File
@@ -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}")
@@ -1855,7 +1936,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
@@ -1871,6 +1952,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
)
+62 -44
View File
@@ -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(
+50 -11
View File
@@ -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,