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

This commit is contained in:
Martin Ziegler
2025-11-29 11:35:10 +01:00
parent 71570c6647
commit 12f763efde
9 changed files with 367 additions and 75 deletions
+13 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>