mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Added feature to take a photo from printer camera when print completes
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user