Просмотр исходного кода

Added feature to take a photo from printer camera when print completes

Martin Ziegler 6 месяцев назад
Родитель
Сommit
12f763efde

+ 13 - 1
backend/app/api/routes/settings.py

@@ -1,3 +1,5 @@
+import shutil
+
 from fastapi import APIRouter, Depends
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
@@ -44,7 +46,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
     for setting in db_settings:
         if setting.key in settings_dict:
             # Parse the value based on the expected type
-            if setting.key in ["auto_archive", "save_thumbnails"]:
+            if setting.key in ["auto_archive", "save_thumbnails", "capture_finish_photo"]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key == "default_filament_cost":
                 settings_dict[setting.key] = float(setting.value)
@@ -87,3 +89,13 @@ async def reset_settings(db: AsyncSession = Depends(get_db)):
     await db.commit()
 
     return DEFAULT_SETTINGS
+
+
+@router.get("/check-ffmpeg")
+async def check_ffmpeg():
+    """Check if ffmpeg is installed and available."""
+    ffmpeg_path = shutil.which("ffmpeg")
+    return {
+        "installed": ffmpeg_path is not None,
+        "path": ffmpeg_path,
+    }

+ 47 - 0
backend/app/main.py

@@ -276,6 +276,53 @@ async def on_print_complete(printer_id: int, data: dict):
             "status": status,
         })
 
+    # Capture finish photo from printer camera
+    try:
+        async with async_session() as db:
+            # Check if finish photo capture is enabled
+            from backend.app.api.routes.settings import get_setting
+            capture_enabled = await get_setting(db, "capture_finish_photo")
+            if capture_enabled is None or capture_enabled.lower() == "true":
+                # Get printer details
+                from backend.app.models.printer import Printer
+                from sqlalchemy import select
+                result = await db.execute(
+                    select(Printer).where(Printer.id == printer_id)
+                )
+                printer = result.scalar_one_or_none()
+
+                if printer and archive_id:
+                    # Get archive to find its directory
+                    from backend.app.models.archive import PrintArchive
+                    result = await db.execute(
+                        select(PrintArchive).where(PrintArchive.id == archive_id)
+                    )
+                    archive = result.scalar_one_or_none()
+
+                    if archive:
+                        from backend.app.services.camera import capture_finish_photo
+                        from pathlib import Path
+
+                        archive_dir = app_settings.base_dir / Path(archive.file_path).parent
+                        photo_filename = await capture_finish_photo(
+                            printer_id=printer_id,
+                            ip_address=printer.ip_address,
+                            access_code=printer.access_code,
+                            model=printer.model,
+                            archive_dir=archive_dir,
+                        )
+
+                        if photo_filename:
+                            # Add photo to archive's photos list
+                            photos = archive.photos or []
+                            photos.append(photo_filename)
+                            archive.photos = photos
+                            await db.commit()
+                            logger.info(f"Added finish photo to archive {archive_id}: {photo_filename}")
+    except Exception as e:
+        import logging
+        logging.getLogger(__name__).warning(f"Finish photo capture failed: {e}")
+
     # Smart plug automation: schedule turn off when print completes
     try:
         async with async_session() as db:

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

@@ -6,6 +6,7 @@ class AppSettings(BaseModel):
 
     auto_archive: bool = Field(default=True, description="Automatically archive prints when completed")
     save_thumbnails: bool = Field(default=True, description="Extract and save preview images from 3MF files")
+    capture_finish_photo: bool = Field(default=True, description="Capture photo from printer camera when print completes")
     default_filament_cost: float = Field(default=25.0, description="Default filament cost per kg")
     currency: str = Field(default="USD", description="Currency for cost tracking")
 
@@ -15,5 +16,6 @@ class AppSettingsUpdate(BaseModel):
 
     auto_archive: bool | None = None
     save_thumbnails: bool | None = None
+    capture_finish_photo: bool | None = None
     default_filament_cost: float | None = None
     currency: str | None = None

+ 192 - 0
backend/app/services/camera.py

@@ -0,0 +1,192 @@
+"""Camera capture service for Bambu Lab printers.
+
+Captures images from the printer's RTSPS camera stream using ffmpeg.
+"""
+
+import asyncio
+import logging
+import subprocess
+from pathlib import Path
+from datetime import datetime
+import uuid
+
+from backend.app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+def get_camera_port(model: str | None) -> int:
+    """Get the RTSPS port based on printer model.
+
+    X1 and H2D series use port 322.
+    P1 and A1 series use port 6000.
+    """
+    if model:
+        model_upper = model.upper()
+        if model_upper.startswith(("X1", "H2")):
+            return 322
+    # Default to 6000 for P1/A1 or unknown models
+    return 6000
+
+
+def build_camera_url(ip_address: str, access_code: str, model: str | None) -> str:
+    """Build the RTSPS URL for the printer camera."""
+    port = get_camera_port(model)
+    return f"rtsps://bblp:{access_code}@{ip_address}:{port}/streaming/live/1"
+
+
+async def capture_camera_frame(
+    ip_address: str,
+    access_code: str,
+    model: str | None,
+    output_path: Path,
+    timeout: int = 30,
+) -> bool:
+    """Capture a single frame from the printer's camera stream.
+
+    Args:
+        ip_address: Printer IP address
+        access_code: Printer access code
+        model: Printer model (X1, H2D, P1, A1, etc.)
+        output_path: Path where to save the captured image
+        timeout: Timeout in seconds for the capture operation
+
+    Returns:
+        True if capture was successful, False otherwise
+    """
+    camera_url = build_camera_url(ip_address, access_code, model)
+
+    # Ensure output directory exists
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+
+    # ffmpeg command to capture a single frame from RTSPS stream
+    # -rtsp_transport tcp: Use TCP for RTSP (more reliable)
+    # -y: Overwrite output file
+    # -frames:v 1: Capture only 1 frame
+    # -q:v 2: High quality JPEG (1-31, lower is better)
+    cmd = [
+        "ffmpeg",
+        "-y",  # Overwrite output
+        "-rtsp_transport", "tcp",
+        "-i", camera_url,
+        "-frames:v", "1",
+        "-q:v", "2",
+        str(output_path),
+    ]
+
+    logger.info(f"Capturing camera frame from {ip_address} (model: {model})")
+
+    try:
+        # Run ffmpeg asynchronously with timeout
+        process = await asyncio.create_subprocess_exec(
+            *cmd,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+
+        try:
+            stdout, stderr = await asyncio.wait_for(
+                process.communicate(),
+                timeout=timeout
+            )
+        except asyncio.TimeoutError:
+            process.kill()
+            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
+
+        if output_path.exists() and output_path.stat().st_size > 0:
+            logger.info(f"Successfully captured camera frame: {output_path}")
+            return True
+        else:
+            logger.error("Camera capture produced no output file")
+            return False
+
+    except FileNotFoundError:
+        logger.error("ffmpeg not found. Please install ffmpeg to enable camera capture.")
+        return False
+    except Exception as e:
+        logger.exception(f"Camera capture failed: {e}")
+        return False
+
+
+async def capture_finish_photo(
+    printer_id: int,
+    ip_address: str,
+    access_code: str,
+    model: str | None,
+    archive_dir: Path,
+) -> str | None:
+    """Capture a finish photo and save it to the archive's photos folder.
+
+    Args:
+        printer_id: ID of the printer
+        ip_address: Printer IP address
+        access_code: Printer access code
+        model: Printer model
+        archive_dir: Directory of the archive (where the 3MF is stored)
+
+    Returns:
+        Filename of the captured photo, or None if capture failed
+    """
+    # Create photos subdirectory
+    photos_dir = archive_dir / "photos"
+    photos_dir.mkdir(parents=True, exist_ok=True)
+
+    # Generate filename with timestamp
+    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+    filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
+    output_path = photos_dir / filename
+
+    success = await capture_camera_frame(
+        ip_address=ip_address,
+        access_code=access_code,
+        model=model,
+        output_path=output_path,
+        timeout=30,
+    )
+
+    if success:
+        logger.info(f"Finish photo saved: {filename}")
+        return filename
+    else:
+        logger.warning(f"Failed to capture finish photo for printer {printer_id}")
+        return None
+
+
+async def test_camera_connection(
+    ip_address: str,
+    access_code: str,
+    model: str | None,
+) -> dict:
+    """Test if the camera stream is accessible.
+
+    Returns dict with success status and any error message.
+    """
+    import tempfile
+
+    with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
+        test_path = Path(f.name)
+
+    try:
+        success = await capture_camera_frame(
+            ip_address=ip_address,
+            access_code=access_code,
+            model=model,
+            output_path=test_path,
+            timeout=15,
+        )
+
+        if success:
+            return {"success": True, "message": "Camera connection successful"}
+        else:
+            return {"success": False, "error": "Failed to capture frame from camera"}
+    finally:
+        # Clean up test file
+        if test_path.exists():
+            test_path.unlink()

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

@@ -141,6 +141,7 @@ export interface BulkUploadResult {
 export interface AppSettings {
   auto_archive: boolean;
   save_thumbnails: boolean;
+  capture_finish_photo: boolean;
   default_filament_cost: number;
   currency: string;
 }
@@ -490,6 +491,8 @@ export const api = {
     }),
   resetSettings: () =>
     request<AppSettings>('/settings/reset', { method: 'POST' }),
+  checkFfmpeg: () =>
+    request<{ installed: boolean; path: string | null }>('/settings/check-ffmpeg'),
 
   // Cloud
   getCloudStatus: () => request<CloudAuthStatus>('/cloud/status'),

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

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Save, Loader2, Check, Plus, Plug } from 'lucide-react';
+import { Save, Loader2, Check, Plus, Plug, AlertTriangle } from 'lucide-react';
 import { api } from '../api/client';
 import type { AppSettings, SmartPlug } from '../api/client';
 import { Card, CardContent, CardHeader } from '../components/Card';
@@ -26,6 +26,11 @@ export function SettingsPage() {
     queryFn: api.getSmartPlugs,
   });
 
+  const { data: ffmpegStatus } = useQuery({
+    queryKey: ['ffmpeg-status'],
+    queryFn: api.checkFfmpeg,
+  });
+
   // Sync local state when settings load
   useEffect(() => {
     if (settings && !localSettings) {
@@ -39,6 +44,7 @@ export function SettingsPage() {
       const changed =
         settings.auto_archive !== localSettings.auto_archive ||
         settings.save_thumbnails !== localSettings.save_thumbnails ||
+        settings.capture_finish_photo !== localSettings.capture_finish_photo ||
         settings.default_filament_cost !== localSettings.default_filament_cost ||
         settings.currency !== localSettings.currency;
       setHasChanges(changed);
@@ -146,6 +152,36 @@ export function SettingsPage() {
                   <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
                 </label>
               </div>
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-white">Capture finish photo</p>
+                  <p className="text-sm text-bambu-gray">
+                    Take a photo from printer camera when print completes
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.capture_finish_photo}
+                    onChange={(e) => updateSetting('capture_finish_photo', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+              {localSettings.capture_finish_photo && ffmpegStatus && !ffmpegStatus.installed && (
+                <div className="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
+                  <AlertTriangle className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
+                  <div className="text-sm">
+                    <p className="text-yellow-500 font-medium">ffmpeg not installed</p>
+                    <p className="text-bambu-gray mt-1">
+                      Camera capture requires ffmpeg. Install it via{' '}
+                      <code className="bg-bambu-dark-tertiary px-1 rounded">brew install ffmpeg</code> (macOS) or{' '}
+                      <code className="bg-bambu-dark-tertiary px-1 rounded">apt install ffmpeg</code> (Linux).
+                    </p>
+                  </div>
+                </div>
+              )}
             </CardContent>
           </Card>
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-B0NJFRfx.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BKPvAiVm.css


+ 2 - 2
static/index.html

@@ -7,8 +7,8 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-IoRlmG2j.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DdfMIrxk.css">
+    <script type="module" crossorigin src="/assets/index-B0NJFRfx.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BKPvAiVm.css">
   </head>
   <body>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов