mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-07 06:31:22 +02:00
Merge pull request #253 from sbcrumb/feature/pushover-image-attachments
Add camera image attachments to Pushover notifications
This commit is contained in:
+107
-3
@@ -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
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user