Browse Source

Add finish photo URL to notification templates (Issue #126)
- Add {finish_photo_url} template variable for print_complete, print_failed,
print_stopped events
- Photo capture now completes before notification is sent (ensures image exists)
- Add External URL setting in Settings → Network (auto-detects from browser)
- Full URL constructed using external_url setting for external services
- Fix Telegram Markdown parsing error when messages contain URLs
- Add backend schema for external_url setting
- Add unit test for finish_photo_url variable passing

maziggy 4 months ago
parent
commit
0354218657

+ 5 - 0
CHANGELOG.md

@@ -12,6 +12,11 @@ All notable changes to Bambuddy will be documented in this file.
   - Falls back to switch entity attributes if no sensors configured
   - Print energy tracking now works correctly for HA plugs (not just Tasmota)
   - New API endpoint: `GET /api/v1/smart-plugs/ha/sensors` to list available energy sensors
+- **Finish Photo in Notifications** - Camera snapshot URL available in notification templates (Issue #126):
+  - New `{finish_photo_url}` template variable for print_complete, print_failed, print_stopped events
+  - Photo is captured before notification is sent (ensures image is available)
+  - New "External URL" setting in Settings → Network (auto-detects from browser)
+  - Full URL constructed for external notification services (Telegram, Email, Discord, etc.)
 
 ### Fixed
 - **Filament cost using wrong default** - Statistics now correctly uses the "Default filament cost (per kg)" setting instead of hardcoded €25 value (Issue #120)

+ 1 - 0
README.md

@@ -107,6 +107,7 @@
 - Custom webhooks
 - Quiet hours & daily digest
 - Customizable message templates
+- Print finish photo URL in notifications
 
 ### 🔧 Integrations
 - [Spoolman](https://github.com/Donkie/Spoolman) filament sync

+ 38 - 6
backend/app/main.py

@@ -1309,8 +1309,8 @@ async def on_print_complete(printer_id: int, data: dict):
         except Exception as e:
             logger.warning(f"[ENERGY-BG] Failed: {e}")
 
-    async def _background_finish_photo():
-        """Capture finish photo in background."""
+    async def _background_finish_photo() -> str | None:
+        """Capture finish photo in background. Returns photo filename if captured."""
         try:
             logger.info(f"[PHOTO-BG] Starting finish photo capture for archive {archive_id}")
 
@@ -1377,11 +1377,15 @@ async def on_print_complete(printer_id: int, data: dict):
                                 archive.photos = photos
                                 await db.commit()
                                 logger.info(f"[PHOTO-BG] Saved: {photo_filename}")
+                                return photo_filename
+            return None
         except Exception as e:
             logger.warning(f"[PHOTO-BG] Failed: {e}")
+            return None
 
     asyncio.create_task(_background_energy_calculation())
-    asyncio.create_task(_background_finish_photo())  # Skips if camera stream active
+    # Photo capture task - result will be used by notifications
+    photo_task = asyncio.create_task(_background_finish_photo())
     log_timing("Background tasks scheduled (energy, photo)")
 
     # Also run smart plug, notifications, and maintenance as background tasks
@@ -1397,10 +1401,10 @@ async def on_print_complete(printer_id: int, data: dict):
         except Exception as e:
             logger.warning(f"[AUTO-OFF-BG] Failed: {e}")
 
-    async def _background_notifications():
+    async def _background_notifications(finish_photo_filename: str | None = None):
         """Send print complete notifications in background."""
         try:
-            logger.info(f"[NOTIFY-BG] Starting notifications for printer {printer_id}")
+            logger.info(f"[NOTIFY-BG] Starting notifications for printer {printer_id}, photo={finish_photo_filename}")
             async with async_session() as db:
                 from backend.app.models.archive import PrintArchive
                 from backend.app.models.printer import Printer
@@ -1419,6 +1423,21 @@ 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
+                        if finish_photo_filename:
+                            from backend.app.api.routes.settings import get_setting
+
+                            external_url = await get_setting(db, "external_url")
+                            if external_url:
+                                external_url = external_url.rstrip("/")
+                                archive_data["finish_photo_url"] = (
+                                    f"{external_url}/api/v1/archives/{archive_id}/photos/{finish_photo_filename}"
+                                )
+                            else:
+                                # Fallback to relative URL (won't work for external services)
+                                archive_data["finish_photo_url"] = (
+                                    f"/api/v1/archives/{archive_id}/photos/{finish_photo_filename}"
+                                )
 
                 await notification_service.on_print_complete(
                     printer_id, printer_name, print_status, data, db, archive_data=archive_data
@@ -1471,8 +1490,21 @@ async def on_print_complete(printer_id: int, data: dict):
             logger.warning(f"[MAINT-BG] Failed: {e}")
 
     asyncio.create_task(_background_smart_plug())
-    asyncio.create_task(_background_notifications())
     asyncio.create_task(_background_maintenance_check())
+
+    # Notification task waits for photo capture to complete first
+    async def _photo_then_notify():
+        """Wait for photo capture, then send notification with photo URL."""
+        try:
+            finish_photo = await photo_task
+            logger.info(f"[PHOTO-NOTIFY] Photo task returned: {finish_photo}")
+            await _background_notifications(finish_photo)
+        except Exception as e:
+            logger.warning(f"[PHOTO-NOTIFY] Failed: {e}")
+            # Still try to send notification without photo
+            await _background_notifications(None)
+
+    asyncio.create_task(_photo_then_notify())
     log_timing("All background tasks scheduled")
 
     # Auto-scan for timelapse if recording was active during the print

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

@@ -26,9 +26,17 @@ class EventType(str, Enum):
 # Available variables for each event type
 EVENT_VARIABLES: dict[str, list[str]] = {
     "print_start": ["printer", "filename", "estimated_time", "timestamp", "app_name"],
-    "print_complete": ["printer", "filename", "duration", "filament_grams", "timestamp", "app_name"],
-    "print_failed": ["printer", "filename", "duration", "reason", "timestamp", "app_name"],
-    "print_stopped": ["printer", "filename", "duration", "timestamp", "app_name"],
+    "print_complete": [
+        "printer",
+        "filename",
+        "duration",
+        "filament_grams",
+        "finish_photo_url",
+        "timestamp",
+        "app_name",
+    ],
+    "print_failed": ["printer", "filename", "duration", "reason", "finish_photo_url", "timestamp", "app_name"],
+    "print_stopped": ["printer", "filename", "duration", "finish_photo_url", "timestamp", "app_name"],
     "print_progress": ["printer", "filename", "progress", "remaining_time", "timestamp", "app_name"],
     "printer_offline": ["printer", "timestamp", "app_name"],
     "printer_error": ["printer", "error_type", "error_detail", "timestamp", "app_name"],
@@ -53,6 +61,7 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "filename": "Benchy.3mf",
         "duration": "1h 18m",
         "filament_grams": "15.2",
+        "finish_photo_url": "/api/v1/archives/123/photos/finish_20240115_154800_abc12345.jpg",
         "timestamp": "2024-01-15 15:48",
         "app_name": "Bambuddy",
     },
@@ -61,6 +70,7 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "filename": "Benchy.3mf",
         "duration": "0h 45m",
         "reason": "Filament runout",
+        "finish_photo_url": "/api/v1/archives/123/photos/finish_20240115_151500_def67890.jpg",
         "timestamp": "2024-01-15 15:15",
         "app_name": "Bambuddy",
     },
@@ -68,6 +78,7 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "printer": "Bambu X1C",
         "filename": "Benchy.3mf",
         "duration": "0h 30m",
+        "finish_photo_url": "/api/v1/archives/123/photos/finish_20240115_150000_ghi11223.jpg",
         "timestamp": "2024-01-15 15:00",
         "app_name": "Bambuddy",
     },

+ 6 - 0
backend/app/schemas/settings.py

@@ -90,6 +90,11 @@ class AppSettings(BaseModel):
     mqtt_topic_prefix: str = Field(default="bambuddy", description="Topic prefix for all published messages")
     mqtt_use_tls: bool = Field(default=False, description="Use TLS/SSL encryption for MQTT connection")
 
+    # External URL for notifications
+    external_url: str = Field(
+        default="", description="External URL where Bambuddy is accessible (for notification images)"
+    )
+
     # Home Assistant integration for smart plug control
     ha_enabled: bool = Field(default=False, description="Enable Home Assistant integration for smart plug control")
     ha_url: str = Field(default="", description="Home Assistant URL (e.g., http://192.168.1.100:8123)")
@@ -156,6 +161,7 @@ class AppSettingsUpdate(BaseModel):
     mqtt_password: str | None = None
     mqtt_topic_prefix: str | None = None
     mqtt_use_tls: bool | None = None
+    external_url: str | None = None
     ha_enabled: bool | None = None
     ha_url: str | None = None
     ha_token: str | None = None

+ 9 - 1
backend/app/services/notification_service.py

@@ -251,11 +251,17 @@ class NotificationService:
             return False, "Bot token and chat ID are required"
 
         url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
+
+        # Check if message contains URLs (which have underscores that break Markdown)
+        # If so, don't use parse_mode to avoid parsing errors
+        has_url = "http://" in message or "https://" in message
+
         data = {
             "chat_id": chat_id,
             "text": message,
-            "parse_mode": "Markdown",
         }
+        if not has_url:
+            data["parse_mode"] = "Markdown"
 
         client = await self._get_client()
         response = await client.post(url, json=data)
@@ -667,6 +673,8 @@ class NotificationService:
                 variables["filament_grams"] = f"{archive_data['actual_filament_grams']:.1f}"
             if status == "failed" and archive_data.get("failure_reason"):
                 variables["reason"] = archive_data["failure_reason"]
+            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}")
 

+ 39 - 0
backend/tests/unit/services/test_notification_service.py

@@ -651,6 +651,45 @@ class TestNotificationVariableFallbacks:
             if captured_variables.get("duration"):
                 assert captured_variables["duration"] != "Unknown"
 
+    @pytest.mark.asyncio
+    async def test_print_complete_with_finish_photo_url(self, service):
+        """Verify finish_photo_url is passed through from archive_data."""
+        mock_db = AsyncMock()
+        mock_provider = MagicMock()
+        mock_provider.id = 1
+
+        captured_variables = {}
+
+        async def capture_build(db, event_type, variables):
+            captured_variables.update(variables)
+            return ("Test", "Test")
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
+        ):
+            mock_get.return_value = [mock_provider]
+
+            await service.on_print_complete(
+                printer_id=1,
+                printer_name="Test",
+                status="completed",
+                data={"subtask_name": "test_print"},
+                db=mock_db,
+                archive_data={
+                    "print_time_seconds": 3600,
+                    "actual_filament_grams": 50.5,
+                    "finish_photo_url": "http://localhost:8000/api/v1/archives/1/photos/finish_test.jpg",
+                },
+            )
+
+            # finish_photo_url should be passed through to template variables
+            assert (
+                captured_variables.get("finish_photo_url")
+                == "http://localhost:8000/api/v1/archives/1/photos/finish_test.jpg"
+            )
+
     @pytest.mark.asyncio
     async def test_print_start_estimated_time_fallback(self, service):
         """Verify estimated time shows 'Unknown' when not available."""

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

@@ -617,6 +617,8 @@ export interface AppSettings {
   mqtt_password: string;
   mqtt_topic_prefix: string;
   mqtt_use_tls: boolean;
+  // External URL for notifications
+  external_url: string;
   // Home Assistant integration
   ha_enabled: boolean;
   ha_url: string;

+ 38 - 1
frontend/src/pages/SettingsPage.tsx

@@ -334,7 +334,12 @@ export function SettingsPage() {
   // Sync local state when settings load
   useEffect(() => {
     if (settings && !localSettings) {
-      setLocalSettings(settings);
+      // Auto-detect external_url from browser if not set
+      const settingsWithExternalUrl = {
+        ...settings,
+        external_url: settings.external_url || window.location.origin,
+      };
+      setLocalSettings(settingsWithExternalUrl);
       // Mark initial load complete after a short delay
       setTimeout(() => {
         isInitialLoadRef.current = false;
@@ -400,6 +405,7 @@ export function SettingsPage() {
       settings.mqtt_password !== localSettings.mqtt_password ||
       settings.mqtt_topic_prefix !== localSettings.mqtt_topic_prefix ||
       settings.mqtt_use_tls !== localSettings.mqtt_use_tls ||
+      settings.external_url !== localSettings.external_url ||
       settings.ha_enabled !== localSettings.ha_enabled ||
       settings.ha_url !== localSettings.ha_url ||
       settings.ha_token !== localSettings.ha_token ||
@@ -460,6 +466,7 @@ export function SettingsPage() {
         mqtt_password: localSettings.mqtt_password,
         mqtt_topic_prefix: localSettings.mqtt_topic_prefix,
         mqtt_use_tls: localSettings.mqtt_use_tls,
+        external_url: localSettings.external_url,
         ha_enabled: localSettings.ha_enabled,
         ha_url: localSettings.ha_url,
         ha_token: localSettings.ha_token,
@@ -1326,6 +1333,36 @@ export function SettingsPage() {
       <div className="flex flex-col lg:flex-row gap-6">
         {/* Left Column - FTP Retry & Home Assistant */}
         <div className="flex-1 lg:max-w-xl space-y-4">
+          {/* External URL */}
+          <Card>
+            <CardHeader>
+              <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                <Globe className="w-5 h-5 text-blue-400" />
+                External URL
+              </h2>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <p className="text-sm text-bambu-gray">
+                The external URL where Bambuddy is accessible. Used for notification images and external integrations.
+              </p>
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">
+                  Bambuddy URL
+                </label>
+                <input
+                  type="text"
+                  value={localSettings.external_url ?? ''}
+                  onChange={(e) => updateSetting('external_url', e.target.value)}
+                  placeholder="http://192.168.1.100:8000"
+                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                />
+                <p className="text-xs text-bambu-gray mt-1">
+                  Include protocol and port (e.g., http://192.168.1.100:8000)
+                </p>
+              </div>
+            </CardContent>
+          </Card>
+
           <Card>
             <CardHeader>
               <h2 className="text-lg font-semibold text-white flex items-center gap-2">

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-jyNRggdg.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BwBqJOA_.js"></script>
+    <script type="module" crossorigin src="/assets/index-jyNRggdg.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-COZJGA_d.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff