mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Add external network camera support for printers
Add support for external network cameras (MJPEG, RTSP, HTTP snapshot) that replace a printer's built-in camera when configured. Features: - Live streaming on printers page (replaces built-in camera) - Finish photo capture from external camera on print complete - Layer-based timelapse: captures frame on each layer change, stitches to MP4 video on print completion Backend changes: - Add external_camera_url, external_camera_type, external_camera_enabled fields to Printer model with database migration - New external_camera.py service: MJPEG/RTSP/snapshot frame capture, connection testing, MJPEG stream generation - New layer_timelapse.py service: TimelapseSession management, layer-by-layer frame capture, ffmpeg video stitching - Add on_layer_change callback to MQTT client and printer manager - Update camera routes with external camera streaming and tracking - Update print lifecycle hooks for timelapse start/stitch/cancel - Add external camera fields to backup/restore - Rate limiting for external camera streams (prevents browser freeze) Frontend changes: - Add external camera configuration UI in Settings > Camera - Per-printer enable toggle, URL input, type selector, test button - Toast notification on save Closes #143
This commit is contained in:
@@ -5,6 +5,12 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
## [0.1.6] - 2026-01-24
|
||||
|
||||
### New Features
|
||||
- **External Network Camera Support** - Add external cameras (MJPEG, RTSP, HTTP snapshot) to replace built-in printer cameras (Issue #143):
|
||||
- Configure per-printer external camera URL and type in Settings → Camera
|
||||
- Live streaming uses external camera when enabled
|
||||
- Finish photo capture uses external camera
|
||||
- Layer-based timelapse: captures frame on each layer change, stitches to MP4 on print completion
|
||||
- Test connection button to verify camera accessibility
|
||||
- **Recalculate Costs Button** - New button on Dashboard to recalculate all archive costs using current filament prices (Issue #120)
|
||||
- **Create Folder from ZIP** - New option in File Manager upload to automatically create a folder named after the ZIP file (Issue #121)
|
||||
- **Multi-File Selection in Printer Files** - Printer card file browser now supports multiple file selection (Issue #144):
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
### 📊 Monitoring & Control
|
||||
- Real-time printer status via WebSocket
|
||||
- Live camera streaming (MJPEG) & snapshots with multi-viewer support
|
||||
- External network camera support (MJPEG, RTSP, HTTP snapshot) with layer-based timelapse
|
||||
- Fan status monitoring (part cooling, auxiliary, chamber)
|
||||
- Printer control (stop, pause, resume, chamber light)
|
||||
- Resizable printer cards (S/M/L/XL)
|
||||
|
||||
@@ -39,6 +39,9 @@ _last_frame_times: dict[int, float] = {}
|
||||
# Track stream start times for each printer
|
||||
_stream_start_times: dict[int, float] = {}
|
||||
|
||||
# Track active external camera streams by printer ID
|
||||
_active_external_streams: set[int] = set()
|
||||
|
||||
|
||||
def get_buffered_frame(printer_id: int) -> bytes | None:
|
||||
"""Get the last buffered frame for a printer from an active stream.
|
||||
@@ -350,7 +353,8 @@ async def camera_stream(
|
||||
This endpoint returns a multipart MJPEG stream that can be used directly
|
||||
in an <img> tag or video player.
|
||||
|
||||
Uses the appropriate protocol based on printer model:
|
||||
Uses external camera if configured, otherwise uses built-in camera:
|
||||
- External: MJPEG, RTSP, or HTTP snapshot
|
||||
- A1/P1: Chamber image protocol (port 6000)
|
||||
- X1/H2/P2: RTSP via ffmpeg (port 322)
|
||||
|
||||
@@ -362,6 +366,50 @@ async def camera_stream(
|
||||
|
||||
printer = await get_printer_or_404(printer_id, db)
|
||||
|
||||
# Check for external camera first
|
||||
if printer.external_camera_enabled and printer.external_camera_url:
|
||||
import time
|
||||
|
||||
from backend.app.services.external_camera import generate_mjpeg_stream
|
||||
|
||||
# Limit external camera FPS to reduce browser load
|
||||
fps = min(max(fps, 1), 15)
|
||||
logger.info(f"Using external camera ({printer.external_camera_type}) for printer {printer_id} at {fps} fps")
|
||||
|
||||
# Track stream start
|
||||
_stream_start_times[printer_id] = time.time()
|
||||
_active_external_streams.add(printer_id)
|
||||
|
||||
async def external_stream_wrapper():
|
||||
"""Wrap external stream to track start/stop and update frame times."""
|
||||
frame_interval = 1.0 / fps
|
||||
last_yield_time = 0.0
|
||||
try:
|
||||
async for frame in generate_mjpeg_stream(
|
||||
printer.external_camera_url, printer.external_camera_type, fps
|
||||
):
|
||||
# Rate limit to prevent overwhelming browser
|
||||
current_time = time.time()
|
||||
elapsed = current_time - last_yield_time
|
||||
if elapsed < frame_interval:
|
||||
await asyncio.sleep(frame_interval - elapsed)
|
||||
last_yield_time = time.time()
|
||||
_last_frame_times[printer_id] = last_yield_time
|
||||
yield frame
|
||||
finally:
|
||||
_active_external_streams.discard(printer_id)
|
||||
logger.info(f"External camera stream ended for printer {printer_id}")
|
||||
|
||||
return StreamingResponse(
|
||||
external_stream_wrapper(),
|
||||
media_type="multipart/x-mixed-replace; boundary=frame",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
},
|
||||
)
|
||||
|
||||
# Validate FPS - A1/P1 models max out at ~5 FPS
|
||||
if is_chamber_image_model(printer.model):
|
||||
fps = min(max(fps, 1), 5)
|
||||
@@ -554,13 +602,18 @@ async def camera_status(printer_id: int):
|
||||
# Check if there's an active stream for this printer
|
||||
has_active_stream = False
|
||||
|
||||
# Check external camera streams
|
||||
if printer_id in _active_external_streams:
|
||||
has_active_stream = True
|
||||
|
||||
# Check ffmpeg/RTSP streams
|
||||
for stream_id in _active_streams:
|
||||
if stream_id.startswith(f"{printer_id}-"):
|
||||
process = _active_streams[stream_id]
|
||||
if process.returncode is None:
|
||||
has_active_stream = True
|
||||
break
|
||||
if not has_active_stream:
|
||||
for stream_id in _active_streams:
|
||||
if stream_id.startswith(f"{printer_id}-"):
|
||||
process = _active_streams[stream_id]
|
||||
if process.returncode is None:
|
||||
has_active_stream = True
|
||||
break
|
||||
|
||||
# Check chamber image streams
|
||||
if not has_active_stream:
|
||||
@@ -597,3 +650,28 @@ async def camera_status(printer_id: int):
|
||||
and (seconds_since_frame is None or seconds_since_frame > 10)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{printer_id}/camera/external/test")
|
||||
async def test_external_camera(
|
||||
printer_id: int,
|
||||
url: str,
|
||||
camera_type: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Test external camera connection.
|
||||
|
||||
Args:
|
||||
printer_id: Printer ID (for authorization)
|
||||
url: Camera URL to test
|
||||
camera_type: Camera type ("mjpeg", "rtsp", "snapshot")
|
||||
|
||||
Returns:
|
||||
Dict with {success: bool, error?: str, resolution?: str}
|
||||
"""
|
||||
# Verify printer exists (for authorization)
|
||||
await get_printer_or_404(printer_id, db)
|
||||
|
||||
from backend.app.services.external_camera import test_connection
|
||||
|
||||
return await test_connection(url, camera_type)
|
||||
|
||||
@@ -404,6 +404,9 @@ async def export_backup(
|
||||
"auto_archive": printer.auto_archive,
|
||||
"print_hours_offset": printer.print_hours_offset,
|
||||
"runtime_seconds": printer.runtime_seconds,
|
||||
"external_camera_url": printer.external_camera_url,
|
||||
"external_camera_type": printer.external_camera_type,
|
||||
"external_camera_enabled": printer.external_camera_enabled,
|
||||
}
|
||||
if include_access_codes:
|
||||
printer_data["access_code"] = printer.access_code
|
||||
@@ -984,6 +987,9 @@ async def import_backup(
|
||||
auto_archive=printer_data.get("auto_archive", True),
|
||||
print_hours_offset=printer_data.get("print_hours_offset", 0.0),
|
||||
runtime_seconds=printer_data.get("runtime_seconds", 0),
|
||||
external_camera_url=printer_data.get("external_camera_url"),
|
||||
external_camera_type=printer_data.get("external_camera_type"),
|
||||
external_camera_enabled=printer_data.get("external_camera_enabled", False),
|
||||
)
|
||||
db.add(printer)
|
||||
restored["printers"] += 1
|
||||
|
||||
@@ -675,6 +675,20 @@ async def run_migrations(conn):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration: Add external camera columns to printers
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE printers ADD COLUMN external_camera_url VARCHAR(500)"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE printers ADD COLUMN external_camera_type VARCHAR(20)"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE printers ADD COLUMN external_camera_enabled BOOLEAN DEFAULT 0"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def seed_notification_templates():
|
||||
"""Seed default notification templates if they don't exist."""
|
||||
|
||||
+114
-27
@@ -798,6 +798,18 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
|
||||
logger.info(f"Created fallback archive {fallback_archive.id} for {print_name} (no 3MF available)")
|
||||
|
||||
# Start timelapse session if external camera is enabled
|
||||
if printer.external_camera_enabled and printer.external_camera_url:
|
||||
from backend.app.services.layer_timelapse import start_session
|
||||
|
||||
start_session(
|
||||
printer_id,
|
||||
fallback_archive.id,
|
||||
printer.external_camera_url,
|
||||
printer.external_camera_type or "mjpeg",
|
||||
)
|
||||
logger.info(f"Started layer timelapse for printer {printer_id}, archive {fallback_archive.id}")
|
||||
|
||||
# Track as active print
|
||||
_active_prints[(printer_id, fallback_archive.filename)] = fallback_archive.id
|
||||
if filename:
|
||||
@@ -872,6 +884,18 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
|
||||
logger.info(f"Created archive {archive.id} for {downloaded_filename}")
|
||||
|
||||
# Start timelapse session if external camera is enabled
|
||||
if printer.external_camera_enabled and printer.external_camera_url:
|
||||
from backend.app.services.layer_timelapse import start_session
|
||||
|
||||
start_session(
|
||||
printer_id,
|
||||
archive.id,
|
||||
printer.external_camera_url,
|
||||
printer.external_camera_type or "mjpeg",
|
||||
)
|
||||
logger.info(f"Started layer timelapse for printer {printer_id}, archive {archive.id}")
|
||||
|
||||
# Record starting energy from smart plug if available
|
||||
try:
|
||||
plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
|
||||
@@ -1365,35 +1389,52 @@ async def on_print_complete(printer_id: int, data: dict):
|
||||
archive_dir = app_settings.base_dir / Path(archive.file_path).parent
|
||||
photo_filename = None
|
||||
|
||||
# Check if camera stream is active - use buffered frame to avoid freeze
|
||||
# Check both RTSP streams (_active_streams) and chamber image streams (_active_chamber_streams)
|
||||
active_for_printer = [k for k in _active_streams if k.startswith(f"{printer_id}-")]
|
||||
active_chamber_for_printer = [
|
||||
k for k in _active_chamber_streams if k.startswith(f"{printer_id}-")
|
||||
]
|
||||
buffered_frame = get_buffered_frame(printer_id)
|
||||
# Check for external camera first
|
||||
if printer.external_camera_enabled and printer.external_camera_url:
|
||||
logger.info("[PHOTO-BG] Using external camera")
|
||||
from backend.app.services.external_camera import capture_frame
|
||||
|
||||
if (active_for_printer or active_chamber_for_printer) and buffered_frame:
|
||||
# Use frame from active stream
|
||||
logger.info("[PHOTO-BG] Using buffered frame from active stream")
|
||||
photos_dir = archive_dir / "photos"
|
||||
photos_dir.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
|
||||
photo_path = photos_dir / photo_filename
|
||||
await asyncio.to_thread(photo_path.write_bytes, buffered_frame)
|
||||
logger.info(f"[PHOTO-BG] Saved buffered frame: {photo_filename}")
|
||||
else:
|
||||
# No active stream - capture new frame
|
||||
from backend.app.services.camera import capture_finish_photo
|
||||
|
||||
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,
|
||||
frame_data = await capture_frame(
|
||||
printer.external_camera_url, printer.external_camera_type or "mjpeg"
|
||||
)
|
||||
if frame_data:
|
||||
photos_dir = archive_dir / "photos"
|
||||
photos_dir.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
|
||||
photo_path = photos_dir / photo_filename
|
||||
await asyncio.to_thread(photo_path.write_bytes, frame_data)
|
||||
logger.info(f"[PHOTO-BG] Saved external camera frame: {photo_filename}")
|
||||
else:
|
||||
# Check if camera stream is active - use buffered frame to avoid freeze
|
||||
# Check both RTSP streams (_active_streams) and chamber image streams (_active_chamber_streams)
|
||||
active_for_printer = [k for k in _active_streams if k.startswith(f"{printer_id}-")]
|
||||
active_chamber_for_printer = [
|
||||
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_for_printer) and buffered_frame:
|
||||
# Use frame from active stream
|
||||
logger.info("[PHOTO-BG] Using buffered frame from active stream")
|
||||
photos_dir = archive_dir / "photos"
|
||||
photos_dir.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
|
||||
photo_path = photos_dir / photo_filename
|
||||
await asyncio.to_thread(photo_path.write_bytes, buffered_frame)
|
||||
logger.info(f"[PHOTO-BG] Saved buffered frame: {photo_filename}")
|
||||
else:
|
||||
# No active stream - capture new frame
|
||||
from backend.app.services.camera import capture_finish_photo
|
||||
|
||||
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:
|
||||
photos = archive.photos or []
|
||||
@@ -1529,6 +1570,43 @@ async def on_print_complete(printer_id: int, data: dict):
|
||||
await _background_notifications(None)
|
||||
|
||||
asyncio.create_task(_photo_then_notify())
|
||||
|
||||
# Stitch external camera layer timelapse if session was active
|
||||
print_status = data.get("status", "completed")
|
||||
|
||||
async def _background_layer_timelapse():
|
||||
"""Stitch layer timelapse and attach to archive."""
|
||||
from backend.app.services.layer_timelapse import cancel_session, on_print_complete as tl_complete
|
||||
|
||||
try:
|
||||
if print_status == "completed":
|
||||
logger.info(f"[LAYER-TL] Stitching layer timelapse for printer {printer_id}")
|
||||
timelapse_path = await tl_complete(printer_id)
|
||||
if timelapse_path and archive_id:
|
||||
logger.info(f"[LAYER-TL] Attaching timelapse {timelapse_path} to archive {archive_id}")
|
||||
async with async_session() as db:
|
||||
service = ArchiveService(db)
|
||||
timelapse_data = await asyncio.to_thread(timelapse_path.read_bytes)
|
||||
await service.attach_timelapse(archive_id, timelapse_data, "layer_timelapse.mp4")
|
||||
# Clean up the temp file
|
||||
await asyncio.to_thread(timelapse_path.unlink, missing_ok=True)
|
||||
logger.info("[LAYER-TL] Layer timelapse attached successfully")
|
||||
elif timelapse_path:
|
||||
# Timelapse created but no archive - just clean up
|
||||
await asyncio.to_thread(timelapse_path.unlink, missing_ok=True)
|
||||
else:
|
||||
# Print failed or cancelled - cancel timelapse session
|
||||
cancel_session(printer_id)
|
||||
logger.info(f"[LAYER-TL] Cancelled layer timelapse for printer {printer_id} (status: {print_status})")
|
||||
except Exception as e:
|
||||
logger.warning(f"[LAYER-TL] Failed: {e}")
|
||||
# Try to cancel session on error
|
||||
try:
|
||||
cancel_session(printer_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
asyncio.create_task(_background_layer_timelapse())
|
||||
log_timing("All background tasks scheduled")
|
||||
|
||||
# Auto-scan for timelapse if recording was active during the print
|
||||
@@ -1928,6 +2006,15 @@ async def lifespan(app: FastAPI):
|
||||
printer_manager.set_print_complete_callback(on_print_complete)
|
||||
printer_manager.set_ams_change_callback(on_ams_change)
|
||||
|
||||
# Layer change callback for external camera timelapse
|
||||
async def on_layer_change(printer_id: int, layer_num: int):
|
||||
"""Capture timelapse frame on layer change."""
|
||||
from backend.app.services.layer_timelapse import on_layer_change as tl_layer_change
|
||||
|
||||
await tl_layer_change(printer_id, layer_num)
|
||||
|
||||
printer_manager.set_layer_change_callback(on_layer_change)
|
||||
|
||||
# Initialize MQTT relay from settings
|
||||
async with async_session() as db:
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
|
||||
@@ -24,6 +24,10 @@ class Printer(Base):
|
||||
last_runtime_update: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, nullable=True
|
||||
) # Last time runtime was updated
|
||||
# External camera configuration
|
||||
external_camera_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
external_camera_type: Mapped[str | None] = mapped_column(String(20), nullable=True) # mjpeg, rtsp, snapshot
|
||||
external_camera_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ class PrinterBase(BaseModel):
|
||||
model: str | None = None
|
||||
location: str | None = None # Group/location name
|
||||
auto_archive: bool = True
|
||||
external_camera_url: str | None = None
|
||||
external_camera_type: str | None = None # "mjpeg", "rtsp", "snapshot"
|
||||
external_camera_enabled: bool = False
|
||||
|
||||
|
||||
class PrinterCreate(PrinterBase):
|
||||
@@ -26,6 +29,9 @@ class PrinterUpdate(BaseModel):
|
||||
is_active: bool | None = None
|
||||
auto_archive: bool | None = None
|
||||
print_hours_offset: float | None = None
|
||||
external_camera_url: str | None = None
|
||||
external_camera_type: str | None = None
|
||||
external_camera_enabled: bool | None = None
|
||||
|
||||
|
||||
class PrinterResponse(PrinterBase):
|
||||
@@ -33,6 +39,9 @@ class PrinterResponse(PrinterBase):
|
||||
is_active: bool
|
||||
nozzle_count: int = 1 # 1 or 2, auto-detected from MQTT
|
||||
print_hours_offset: float = 0.0
|
||||
external_camera_url: str | None = None
|
||||
external_camera_type: str | None = None
|
||||
external_camera_enabled: bool = False
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@@ -253,6 +253,7 @@ class BambuMQTTClient:
|
||||
on_print_start: Callable[[dict], None] | None = None,
|
||||
on_print_complete: Callable[[dict], None] | None = None,
|
||||
on_ams_change: Callable[[list], None] | None = None,
|
||||
on_layer_change: Callable[[int], None] | None = None,
|
||||
):
|
||||
self.ip_address = ip_address
|
||||
self.serial_number = serial_number
|
||||
@@ -261,6 +262,7 @@ class BambuMQTTClient:
|
||||
self.on_print_start = on_print_start
|
||||
self.on_print_complete = on_print_complete
|
||||
self.on_ams_change = on_ams_change
|
||||
self.on_layer_change = on_layer_change
|
||||
|
||||
self.state = PrinterState()
|
||||
self._client: mqtt.Client | None = None
|
||||
@@ -1030,7 +1032,12 @@ class BambuMQTTClient:
|
||||
)
|
||||
self.state.mc_print_sub_stage = new_sub_stage
|
||||
if "layer_num" in data:
|
||||
self.state.layer_num = int(data["layer_num"])
|
||||
new_layer = int(data["layer_num"])
|
||||
old_layer = self.state.layer_num
|
||||
self.state.layer_num = new_layer
|
||||
# Trigger layer change callback if layer increased
|
||||
if new_layer > old_layer and self.on_layer_change:
|
||||
self.on_layer_change(new_layer)
|
||||
if "total_layer_num" in data:
|
||||
self.state.total_layers = int(data["total_layer_num"])
|
||||
|
||||
@@ -1736,6 +1743,8 @@ class BambuMQTTClient:
|
||||
if is_new_print or is_file_change:
|
||||
# Clear any old HMS errors when a new print starts
|
||||
self.state.hms_errors = []
|
||||
# Reset layer tracking for new print (needed for layer-based timelapse)
|
||||
self.state.layer_num = 0
|
||||
# Reset completion tracking for new print
|
||||
self._was_running = True
|
||||
self._completion_triggered = False
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
"""External network camera service.
|
||||
|
||||
Supports MJPEG streams, RTSP streams (via ffmpeg), and HTTP snapshot URLs.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
from collections.abc import AsyncGenerator
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_ffmpeg_path() -> str | None:
|
||||
"""Get the path to ffmpeg executable."""
|
||||
# Try shutil.which first
|
||||
path = shutil.which("ffmpeg")
|
||||
if path:
|
||||
return path
|
||||
# Check common locations (systemd services may have limited PATH)
|
||||
for common_path in ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"]:
|
||||
if Path(common_path).exists():
|
||||
return common_path
|
||||
return None
|
||||
|
||||
|
||||
async def capture_frame(url: str, camera_type: str, timeout: int = 15) -> bytes | None:
|
||||
"""Capture single frame from external camera.
|
||||
|
||||
Args:
|
||||
url: Camera URL (MJPEG stream, RTSP URL, or HTTP snapshot URL)
|
||||
camera_type: "mjpeg", "rtsp", or "snapshot"
|
||||
timeout: Connection timeout in seconds
|
||||
|
||||
Returns:
|
||||
JPEG bytes or None on failure
|
||||
"""
|
||||
logger.debug(f"capture_frame called: type={camera_type}, url={url[:50] if url else 'None'}...")
|
||||
if camera_type == "mjpeg":
|
||||
return await _capture_mjpeg_frame(url, timeout)
|
||||
elif camera_type == "rtsp":
|
||||
return await _capture_rtsp_frame(url, timeout)
|
||||
elif camera_type == "snapshot":
|
||||
return await _capture_snapshot(url, timeout)
|
||||
else:
|
||||
logger.warning(f"Unknown camera type: {camera_type}")
|
||||
return None
|
||||
|
||||
|
||||
async def _capture_mjpeg_frame(url: str, timeout: int) -> bytes | None:
|
||||
"""Extract single frame from MJPEG stream."""
|
||||
try:
|
||||
async with (
|
||||
aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session,
|
||||
session.get(url) as response,
|
||||
):
|
||||
if response.status != 200:
|
||||
logger.error(f"MJPEG stream returned status {response.status}")
|
||||
return None
|
||||
|
||||
# Read chunks until we find a complete JPEG frame
|
||||
buffer = b""
|
||||
jpeg_start = b"\xff\xd8"
|
||||
jpeg_end = b"\xff\xd9"
|
||||
|
||||
async for chunk in response.content.iter_chunked(8192):
|
||||
buffer += chunk
|
||||
|
||||
# Look for complete JPEG frame
|
||||
start_idx = buffer.find(jpeg_start)
|
||||
if start_idx == -1:
|
||||
continue
|
||||
|
||||
end_idx = buffer.find(jpeg_end, start_idx + 2)
|
||||
if end_idx != -1:
|
||||
# Found complete frame
|
||||
frame = buffer[start_idx : end_idx + 2]
|
||||
return frame
|
||||
|
||||
# Keep searching, but limit buffer size
|
||||
if len(buffer) > 5 * 1024 * 1024: # 5MB limit
|
||||
logger.warning("MJPEG buffer exceeded 5MB without finding frame")
|
||||
return None
|
||||
|
||||
except TimeoutError:
|
||||
logger.warning(f"MJPEG frame capture timed out after {timeout}s")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"MJPEG frame capture failed: {e}")
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
|
||||
"""Capture frame from RTSP using ffmpeg."""
|
||||
ffmpeg = get_ffmpeg_path()
|
||||
if not ffmpeg:
|
||||
logger.error("ffmpeg not found - required for RTSP capture")
|
||||
return None
|
||||
|
||||
# Use ffmpeg to grab a single frame from RTSP stream
|
||||
# ffmpeg handles both rtsp:// and rtsps:// URLs automatically
|
||||
cmd = [
|
||||
ffmpeg,
|
||||
"-rtsp_transport",
|
||||
"tcp",
|
||||
"-i",
|
||||
url,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-vcodec",
|
||||
"mjpeg",
|
||||
"-q:v",
|
||||
"2",
|
||||
"-",
|
||||
]
|
||||
|
||||
try:
|
||||
print(f"[EXT-CAM] Running ffmpeg command: {' '.join(cmd[:6])}...")
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
||||
print(
|
||||
f"[EXT-CAM] ffmpeg returned: code={process.returncode}, stdout={len(stdout)} bytes, stderr={len(stderr)} bytes"
|
||||
)
|
||||
|
||||
if process.returncode != 0:
|
||||
logger.error(f"ffmpeg RTSP capture failed: {stderr.decode()[:200]}")
|
||||
print(f"[EXT-CAM] ffmpeg error: {stderr.decode()[:300]}")
|
||||
return None
|
||||
|
||||
if not stdout or len(stdout) < 100:
|
||||
logger.error("ffmpeg returned empty or too small frame")
|
||||
return None
|
||||
|
||||
return stdout
|
||||
|
||||
except TimeoutError:
|
||||
logger.warning(f"RTSP frame capture timed out after {timeout}s")
|
||||
if process:
|
||||
process.kill()
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"RTSP frame capture failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
|
||||
"""Fetch snapshot from HTTP URL."""
|
||||
try:
|
||||
async with (
|
||||
aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session,
|
||||
session.get(url) as response,
|
||||
):
|
||||
if response.status != 200:
|
||||
logger.error(f"Snapshot URL returned status {response.status}")
|
||||
return None
|
||||
|
||||
data = await response.read()
|
||||
|
||||
# Validate it looks like JPEG
|
||||
if not data.startswith(b"\xff\xd8"):
|
||||
logger.warning("Snapshot does not appear to be JPEG")
|
||||
# Still return it - might be valid with different header
|
||||
|
||||
return data
|
||||
|
||||
except TimeoutError:
|
||||
logger.warning(f"Snapshot capture timed out after {timeout}s")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Snapshot capture failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def test_connection(url: str, camera_type: str) -> dict:
|
||||
"""Test camera connection.
|
||||
|
||||
Returns:
|
||||
Dict with {success: bool, error?: str, resolution?: str}
|
||||
"""
|
||||
print(f"[EXT-CAM] Testing camera connection: type={camera_type}, url={url[:50]}...")
|
||||
logger.info(f"Testing camera connection: type={camera_type}, url={url[:50]}...")
|
||||
try:
|
||||
frame = await capture_frame(url, camera_type, timeout=10)
|
||||
print(f"[EXT-CAM] Capture result: {len(frame) if frame else 0} bytes")
|
||||
logger.info(f"Capture result: {len(frame) if frame else 0} bytes")
|
||||
|
||||
if frame:
|
||||
# Try to get resolution from JPEG header
|
||||
resolution = None
|
||||
try:
|
||||
# Simple JPEG dimension extraction
|
||||
# SOF0 marker is FF C0, followed by length, precision, height, width
|
||||
sof_markers = [b"\xff\xc0", b"\xff\xc1", b"\xff\xc2"]
|
||||
for marker in sof_markers:
|
||||
idx = frame.find(marker)
|
||||
if idx != -1 and idx + 9 <= len(frame):
|
||||
height = (frame[idx + 5] << 8) | frame[idx + 6]
|
||||
width = (frame[idx + 7] << 8) | frame[idx + 8]
|
||||
resolution = f"{width}x{height}"
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"success": True, "resolution": resolution}
|
||||
else:
|
||||
return {"success": False, "error": "Failed to capture frame from camera"}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> AsyncGenerator[bytes, None]:
|
||||
"""Generator yielding MJPEG frames for streaming.
|
||||
|
||||
Args:
|
||||
url: Camera URL
|
||||
camera_type: "mjpeg", "rtsp", or "snapshot"
|
||||
fps: Target frames per second
|
||||
|
||||
Yields:
|
||||
MJPEG frame data with HTTP multipart boundaries
|
||||
"""
|
||||
frame_interval = 1.0 / max(fps, 1)
|
||||
last_frame_time = 0.0
|
||||
|
||||
if camera_type == "mjpeg":
|
||||
# Proxy MJPEG stream directly
|
||||
async for frame in _stream_mjpeg(url):
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
if current_time - last_frame_time >= frame_interval:
|
||||
last_frame_time = current_time
|
||||
yield _format_mjpeg_frame(frame)
|
||||
|
||||
elif camera_type == "rtsp":
|
||||
# Use ffmpeg to convert RTSP to MJPEG
|
||||
async for frame in _stream_rtsp(url, fps):
|
||||
yield _format_mjpeg_frame(frame)
|
||||
|
||||
elif camera_type == "snapshot":
|
||||
# Poll snapshot URL at interval
|
||||
while True:
|
||||
try:
|
||||
frame = await _capture_snapshot(url, timeout=10)
|
||||
if frame:
|
||||
yield _format_mjpeg_frame(frame)
|
||||
await asyncio.sleep(frame_interval)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Snapshot poll failed: {e}")
|
||||
await asyncio.sleep(frame_interval)
|
||||
|
||||
|
||||
def _format_mjpeg_frame(frame: bytes) -> bytes:
|
||||
"""Format frame for MJPEG HTTP response."""
|
||||
return (
|
||||
b"--frame\r\n"
|
||||
b"Content-Type: image/jpeg\r\n"
|
||||
b"Content-Length: " + str(len(frame)).encode() + b"\r\n"
|
||||
b"\r\n" + frame + b"\r\n"
|
||||
)
|
||||
|
||||
|
||||
async def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]:
|
||||
"""Stream frames from MJPEG URL."""
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=None, sock_read=30)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session, session.get(url) as response:
|
||||
if response.status != 200:
|
||||
logger.error(f"MJPEG stream returned status {response.status}")
|
||||
return
|
||||
|
||||
buffer = b""
|
||||
jpeg_start = b"\xff\xd8"
|
||||
jpeg_end = b"\xff\xd9"
|
||||
|
||||
async for chunk in response.content.iter_chunked(8192):
|
||||
buffer += chunk
|
||||
|
||||
# Extract complete frames from buffer
|
||||
while True:
|
||||
start_idx = buffer.find(jpeg_start)
|
||||
if start_idx == -1:
|
||||
buffer = buffer[-2:] if len(buffer) > 2 else buffer
|
||||
break
|
||||
|
||||
if start_idx > 0:
|
||||
buffer = buffer[start_idx:]
|
||||
|
||||
end_idx = buffer.find(jpeg_end, 2)
|
||||
if end_idx == -1:
|
||||
break
|
||||
|
||||
frame = buffer[: end_idx + 2]
|
||||
buffer = buffer[end_idx + 2 :]
|
||||
yield frame
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("MJPEG stream cancelled")
|
||||
except Exception as e:
|
||||
logger.error(f"MJPEG stream error: {e}")
|
||||
|
||||
|
||||
async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
|
||||
"""Stream frames from RTSP URL via ffmpeg."""
|
||||
ffmpeg = get_ffmpeg_path()
|
||||
if not ffmpeg:
|
||||
logger.error("ffmpeg not found - required for RTSP streaming")
|
||||
return
|
||||
|
||||
# ffmpeg handles both rtsp:// and rtsps:// URLs automatically
|
||||
cmd = [
|
||||
ffmpeg,
|
||||
"-rtsp_transport",
|
||||
"tcp",
|
||||
"-rtsp_flags",
|
||||
"prefer_tcp",
|
||||
"-timeout",
|
||||
"30000000",
|
||||
"-buffer_size",
|
||||
"1024000",
|
||||
"-max_delay",
|
||||
"500000",
|
||||
"-i",
|
||||
url,
|
||||
"-f",
|
||||
"mjpeg",
|
||||
"-q:v",
|
||||
"5",
|
||||
"-r",
|
||||
str(fps),
|
||||
"-an",
|
||||
"-",
|
||||
]
|
||||
|
||||
process = None
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
# Give ffmpeg a moment to start and check for immediate failures
|
||||
await asyncio.sleep(0.5)
|
||||
if process.returncode is not None:
|
||||
stderr = await process.stderr.read()
|
||||
logger.error(f"ffmpeg RTSP stream failed immediately: {stderr.decode()[:300]}")
|
||||
return
|
||||
|
||||
buffer = b""
|
||||
jpeg_start = b"\xff\xd8"
|
||||
jpeg_end = b"\xff\xd9"
|
||||
|
||||
while True:
|
||||
try:
|
||||
chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=30.0)
|
||||
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
buffer += chunk
|
||||
|
||||
# Extract complete frames
|
||||
while True:
|
||||
start_idx = buffer.find(jpeg_start)
|
||||
if start_idx == -1:
|
||||
buffer = buffer[-2:] if len(buffer) > 2 else buffer
|
||||
break
|
||||
|
||||
if start_idx > 0:
|
||||
buffer = buffer[start_idx:]
|
||||
|
||||
end_idx = buffer.find(jpeg_end, 2)
|
||||
if end_idx == -1:
|
||||
break
|
||||
|
||||
frame = buffer[: end_idx + 2]
|
||||
buffer = buffer[end_idx + 2 :]
|
||||
yield frame
|
||||
|
||||
except TimeoutError:
|
||||
logger.warning("RTSP stream read timeout")
|
||||
break
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("RTSP stream cancelled")
|
||||
except Exception as e:
|
||||
logger.error(f"RTSP stream error: {e}")
|
||||
finally:
|
||||
if process and process.returncode is None:
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=2.0)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Layer-based timelapse for external cameras.
|
||||
|
||||
Captures a frame on each layer change and stitches them into a video on print completion.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.services.external_camera import capture_frame
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Active timelapse sessions: {printer_id: TimelapseSession}
|
||||
_active_sessions: dict[int, "TimelapseSession"] = {}
|
||||
|
||||
|
||||
def get_ffmpeg_path() -> str | None:
|
||||
"""Get the path to ffmpeg executable."""
|
||||
# Try shutil.which first
|
||||
path = shutil.which("ffmpeg")
|
||||
if path:
|
||||
return path
|
||||
# Check common locations (systemd services may have limited PATH)
|
||||
for common_path in ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"]:
|
||||
if Path(common_path).exists():
|
||||
return common_path
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TimelapseSession:
|
||||
"""Active timelapse recording session."""
|
||||
|
||||
printer_id: int
|
||||
archive_id: int | None
|
||||
camera_url: str
|
||||
camera_type: str
|
||||
last_layer: int = -1
|
||||
frame_count: int = 0
|
||||
session_id: str = field(default_factory=lambda: datetime.now().strftime("%Y%m%d_%H%M%S"))
|
||||
frames_dir: Path = field(init=False)
|
||||
|
||||
def __post_init__(self):
|
||||
self.frames_dir = settings.base_dir / "timelapse_frames" / str(self.printer_id) / self.session_id
|
||||
self.frames_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"Created timelapse session {self.session_id} for printer {self.printer_id}")
|
||||
|
||||
async def capture_layer(self, layer_num: int) -> bool:
|
||||
"""Capture frame if layer changed.
|
||||
|
||||
Args:
|
||||
layer_num: Current layer number from printer
|
||||
|
||||
Returns:
|
||||
True if frame was captured, False otherwise
|
||||
"""
|
||||
# Only capture if layer increased
|
||||
if layer_num <= self.last_layer:
|
||||
return False
|
||||
|
||||
self.last_layer = layer_num
|
||||
|
||||
try:
|
||||
frame_data = await capture_frame(self.camera_url, self.camera_type)
|
||||
if frame_data:
|
||||
frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg"
|
||||
await asyncio.to_thread(frame_path.write_bytes, frame_data)
|
||||
self.frame_count += 1
|
||||
logger.debug(f"Captured layer {layer_num} for printer {self.printer_id} (frame {self.frame_count})")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Failed to capture frame for layer {layer_num}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error capturing timelapse frame: {e}")
|
||||
return False
|
||||
|
||||
async def stitch(self, output_path: Path, fps: int = 30) -> bool:
|
||||
"""Create MP4 from captured frames using ffmpeg.
|
||||
|
||||
Args:
|
||||
output_path: Path for output video file
|
||||
fps: Frames per second for output video
|
||||
|
||||
Returns:
|
||||
True if stitching succeeded, False otherwise
|
||||
"""
|
||||
if self.frame_count == 0:
|
||||
logger.warning("No frames to stitch")
|
||||
return False
|
||||
|
||||
ffmpeg = get_ffmpeg_path()
|
||||
if not ffmpeg:
|
||||
logger.error("ffmpeg not found - required for timelapse stitching")
|
||||
return False
|
||||
|
||||
# Find all frame files and create a sequential list
|
||||
# This handles gaps in layer numbers (e.g., if some captures failed)
|
||||
frame_files = sorted(self.frames_dir.glob("layer_*.jpg"))
|
||||
if not frame_files:
|
||||
logger.warning("No frame files found in timelapse directory")
|
||||
return False
|
||||
|
||||
# Create a concat file listing all frames
|
||||
concat_file = self.frames_dir / "frames.txt"
|
||||
try:
|
||||
with open(concat_file, "w") as f:
|
||||
for frame in frame_files:
|
||||
# Each frame shown for 1/fps duration
|
||||
f.write(f"file '{frame.name}'\n")
|
||||
f.write(f"duration {1.0 / fps}\n")
|
||||
# Add last frame again (required by concat demuxer)
|
||||
if frame_files:
|
||||
f.write(f"file '{frame_files[-1].name}'\n")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create concat file: {e}")
|
||||
return False
|
||||
|
||||
# Use ffmpeg concat demuxer for variable-gap frame sequences
|
||||
cmd = [
|
||||
ffmpeg,
|
||||
"-y", # Overwrite output
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
str(concat_file),
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-crf",
|
||||
"23",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(self.frames_dir), # Run in frames dir so relative paths work
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=300)
|
||||
|
||||
if process.returncode != 0:
|
||||
logger.error(f"ffmpeg timelapse stitch failed: {stderr.decode()[:500]}")
|
||||
return False
|
||||
|
||||
logger.info(f"Created timelapse video: {output_path} ({self.frame_count} frames)")
|
||||
return True
|
||||
|
||||
except TimeoutError:
|
||||
logger.error("Timelapse stitching timed out")
|
||||
if process:
|
||||
process.kill()
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Timelapse stitch failed: {e}")
|
||||
return False
|
||||
|
||||
def cleanup(self):
|
||||
"""Remove temporary frames directory."""
|
||||
try:
|
||||
if self.frames_dir.exists():
|
||||
shutil.rmtree(self.frames_dir, ignore_errors=True)
|
||||
logger.info(f"Cleaned up timelapse frames for session {self.session_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup timelapse frames: {e}")
|
||||
|
||||
|
||||
def start_session(printer_id: int, archive_id: int | None, url: str, cam_type: str) -> TimelapseSession:
|
||||
"""Start new timelapse session for a printer.
|
||||
|
||||
Args:
|
||||
printer_id: The printer ID
|
||||
archive_id: Associated print archive ID (optional)
|
||||
url: External camera URL
|
||||
cam_type: Camera type ("mjpeg", "rtsp", "snapshot")
|
||||
|
||||
Returns:
|
||||
The new TimelapseSession
|
||||
"""
|
||||
# Cancel any existing session
|
||||
cancel_session(printer_id)
|
||||
|
||||
session = TimelapseSession(
|
||||
printer_id=printer_id,
|
||||
archive_id=archive_id,
|
||||
camera_url=url,
|
||||
camera_type=cam_type,
|
||||
)
|
||||
_active_sessions[printer_id] = session
|
||||
logger.info(f"Started timelapse session for printer {printer_id}")
|
||||
return session
|
||||
|
||||
|
||||
def get_session(printer_id: int) -> TimelapseSession | None:
|
||||
"""Get active timelapse session for a printer."""
|
||||
return _active_sessions.get(printer_id)
|
||||
|
||||
|
||||
async def on_layer_change(printer_id: int, layer_num: int):
|
||||
"""Called on layer change - captures frame if session active.
|
||||
|
||||
Args:
|
||||
printer_id: The printer ID
|
||||
layer_num: Current layer number
|
||||
"""
|
||||
session = get_session(printer_id)
|
||||
if session:
|
||||
await session.capture_layer(layer_num)
|
||||
|
||||
|
||||
async def on_print_complete(printer_id: int) -> Path | None:
|
||||
"""Stitch timelapse and return path. Cleans up session.
|
||||
|
||||
Args:
|
||||
printer_id: The printer ID
|
||||
|
||||
Returns:
|
||||
Path to stitched video, or None if no session or stitching failed
|
||||
"""
|
||||
session = _active_sessions.pop(printer_id, None)
|
||||
if not session:
|
||||
return None
|
||||
|
||||
if session.frame_count == 0:
|
||||
logger.info(f"No timelapse frames captured for printer {printer_id}")
|
||||
session.cleanup()
|
||||
return None
|
||||
|
||||
# Create output path in parent of frames dir
|
||||
output_path = session.frames_dir.parent / f"timelapse_{session.session_id}.mp4"
|
||||
|
||||
try:
|
||||
success = await session.stitch(output_path)
|
||||
if success:
|
||||
# Cleanup frames after successful stitch
|
||||
session.cleanup()
|
||||
return output_path
|
||||
else:
|
||||
session.cleanup()
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Timelapse completion failed: {e}")
|
||||
session.cleanup()
|
||||
return None
|
||||
|
||||
|
||||
def cancel_session(printer_id: int):
|
||||
"""Cancel and cleanup timelapse session (on print fail/cancel).
|
||||
|
||||
Args:
|
||||
printer_id: The printer ID
|
||||
"""
|
||||
session = _active_sessions.pop(printer_id, None)
|
||||
if session:
|
||||
session.cleanup()
|
||||
logger.info(f"Cancelled timelapse session for printer {printer_id}")
|
||||
|
||||
|
||||
def get_active_sessions() -> dict[int, TimelapseSession]:
|
||||
"""Get all active timelapse sessions."""
|
||||
return _active_sessions.copy()
|
||||
@@ -66,6 +66,7 @@ class PrinterManager:
|
||||
self._on_print_complete: Callable[[int, dict], None] | None = None
|
||||
self._on_status_change: Callable[[int, PrinterState], None] | None = None
|
||||
self._on_ams_change: Callable[[int, list], None] | None = None
|
||||
self._on_layer_change: Callable[[int, int], None] | None = None
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
def get_printer(self, printer_id: int) -> PrinterInfo | None:
|
||||
@@ -92,6 +93,10 @@ class PrinterManager:
|
||||
"""Set callback for AMS data change events."""
|
||||
self._on_ams_change = callback
|
||||
|
||||
def set_layer_change_callback(self, callback: Callable[[int, int], None]):
|
||||
"""Set callback for layer change events. Receives (printer_id, layer_num)."""
|
||||
self._on_layer_change = callback
|
||||
|
||||
def _schedule_async(self, coro):
|
||||
"""Schedule an async coroutine from a sync context.
|
||||
|
||||
@@ -135,6 +140,10 @@ class PrinterManager:
|
||||
if self._on_ams_change:
|
||||
self._schedule_async(self._on_ams_change(printer_id, ams_data))
|
||||
|
||||
def on_layer_change(layer_num: int):
|
||||
if self._on_layer_change:
|
||||
self._schedule_async(self._on_layer_change(printer_id, layer_num))
|
||||
|
||||
client = BambuMQTTClient(
|
||||
ip_address=printer.ip_address,
|
||||
serial_number=printer.serial_number,
|
||||
@@ -143,6 +152,7 @@ class PrinterManager:
|
||||
on_print_start=on_print_start,
|
||||
on_print_complete=on_print_complete,
|
||||
on_ams_change=on_ams_change,
|
||||
on_layer_change=on_layer_change,
|
||||
)
|
||||
|
||||
client.connect()
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
Tests for the external camera service.
|
||||
|
||||
These tests cover pure functions and frame parsing logic.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestFormatMjpegFrame:
|
||||
"""Tests for MJPEG frame formatting."""
|
||||
|
||||
def test_format_mjpeg_frame_basic(self):
|
||||
"""Verify MJPEG frame is formatted correctly with boundary and headers."""
|
||||
from backend.app.services.external_camera import _format_mjpeg_frame
|
||||
|
||||
# Minimal JPEG data (just SOI and EOI markers)
|
||||
jpeg_data = b"\xff\xd8\xff\xd9"
|
||||
|
||||
result = _format_mjpeg_frame(jpeg_data)
|
||||
|
||||
# Check boundary
|
||||
assert result.startswith(b"--frame\r\n")
|
||||
# Check content type
|
||||
assert b"Content-Type: image/jpeg\r\n" in result
|
||||
# Check content length
|
||||
assert b"Content-Length: 4\r\n" in result
|
||||
# Check frame data is included
|
||||
assert jpeg_data in result
|
||||
# Check ends with CRLF
|
||||
assert result.endswith(b"\r\n")
|
||||
|
||||
def test_format_mjpeg_frame_larger_data(self):
|
||||
"""Verify content length is correct for larger frames."""
|
||||
from backend.app.services.external_camera import _format_mjpeg_frame
|
||||
|
||||
# Simulate a larger JPEG (1000 bytes)
|
||||
jpeg_data = b"\xff\xd8" + b"\x00" * 996 + b"\xff\xd9"
|
||||
|
||||
result = _format_mjpeg_frame(jpeg_data)
|
||||
|
||||
assert b"Content-Length: 1000\r\n" in result
|
||||
|
||||
|
||||
class TestGetFfmpegPath:
|
||||
"""Tests for ffmpeg path detection."""
|
||||
|
||||
def test_get_ffmpeg_path_from_shutil_which(self):
|
||||
"""Verify ffmpeg found via shutil.which is returned."""
|
||||
from backend.app.services.external_camera import get_ffmpeg_path
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/ffmpeg"):
|
||||
result = get_ffmpeg_path()
|
||||
assert result == "/usr/bin/ffmpeg"
|
||||
|
||||
def test_get_ffmpeg_path_fallback_to_common_paths(self):
|
||||
"""Verify common paths are checked when shutil.which fails."""
|
||||
from backend.app.services.external_camera import get_ffmpeg_path
|
||||
|
||||
with patch("shutil.which", return_value=None), patch("pathlib.Path.exists") as mock_exists:
|
||||
# First common path exists
|
||||
mock_exists.return_value = True
|
||||
result = get_ffmpeg_path()
|
||||
assert result in ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"]
|
||||
|
||||
def test_get_ffmpeg_path_returns_none_when_not_found(self):
|
||||
"""Verify None is returned when ffmpeg not found anywhere."""
|
||||
from backend.app.services.external_camera import get_ffmpeg_path
|
||||
|
||||
with patch("shutil.which", return_value=None), patch("pathlib.Path.exists", return_value=False):
|
||||
result = get_ffmpeg_path()
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestJpegFrameExtraction:
|
||||
"""Tests for JPEG frame extraction from buffer."""
|
||||
|
||||
def test_extract_single_frame_from_buffer(self):
|
||||
"""Test extracting a complete JPEG frame from buffer."""
|
||||
# JPEG markers
|
||||
jpeg_start = b"\xff\xd8"
|
||||
jpeg_end = b"\xff\xd9"
|
||||
|
||||
# Create a buffer with one complete frame
|
||||
frame_content = b"\x00" * 100
|
||||
buffer = jpeg_start + frame_content + jpeg_end
|
||||
|
||||
# Find frame boundaries
|
||||
start_idx = buffer.find(jpeg_start)
|
||||
end_idx = buffer.find(jpeg_end, start_idx + 2)
|
||||
|
||||
assert start_idx == 0
|
||||
assert end_idx == 102
|
||||
|
||||
# Extract frame
|
||||
frame = buffer[start_idx : end_idx + 2]
|
||||
assert frame == buffer
|
||||
assert len(frame) == 104
|
||||
|
||||
def test_extract_frame_with_leading_garbage(self):
|
||||
"""Test extracting frame when buffer has leading garbage data."""
|
||||
jpeg_start = b"\xff\xd8"
|
||||
jpeg_end = b"\xff\xd9"
|
||||
|
||||
# Buffer with garbage before the JPEG
|
||||
garbage = b"\x00\x01\x02\x03"
|
||||
frame_content = b"\xff" * 50
|
||||
buffer = garbage + jpeg_start + frame_content + jpeg_end
|
||||
|
||||
start_idx = buffer.find(jpeg_start)
|
||||
assert start_idx == 4 # After garbage
|
||||
|
||||
end_idx = buffer.find(jpeg_end, start_idx + 2)
|
||||
frame = buffer[start_idx : end_idx + 2]
|
||||
|
||||
assert frame.startswith(jpeg_start)
|
||||
assert frame.endswith(jpeg_end)
|
||||
assert len(frame) == 54 # 2 + 50 + 2
|
||||
|
||||
def test_incomplete_frame_detection(self):
|
||||
"""Test detection of incomplete frame (no end marker)."""
|
||||
jpeg_start = b"\xff\xd8"
|
||||
|
||||
# Incomplete buffer - no end marker
|
||||
buffer = jpeg_start + b"\x00" * 100
|
||||
|
||||
start_idx = buffer.find(jpeg_start)
|
||||
end_idx = buffer.find(b"\xff\xd9", start_idx + 2)
|
||||
|
||||
assert start_idx == 0
|
||||
assert end_idx == -1 # Not found
|
||||
|
||||
def test_multiple_frames_in_buffer(self):
|
||||
"""Test extracting first frame when buffer contains multiple frames."""
|
||||
jpeg_start = b"\xff\xd8"
|
||||
jpeg_end = b"\xff\xd9"
|
||||
|
||||
# Two complete frames
|
||||
frame1 = jpeg_start + b"\x01" * 10 + jpeg_end
|
||||
frame2 = jpeg_start + b"\x02" * 20 + jpeg_end
|
||||
buffer = frame1 + frame2
|
||||
|
||||
# Extract first frame
|
||||
start_idx = buffer.find(jpeg_start)
|
||||
end_idx = buffer.find(jpeg_end, start_idx + 2)
|
||||
first_frame = buffer[start_idx : end_idx + 2]
|
||||
|
||||
assert first_frame == frame1
|
||||
assert len(first_frame) == 14
|
||||
|
||||
# Remaining buffer should contain second frame
|
||||
remaining = buffer[end_idx + 2 :]
|
||||
assert remaining == frame2
|
||||
|
||||
|
||||
class TestCameraTypeValidation:
|
||||
"""Tests for camera type handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capture_frame_unknown_type_returns_none(self):
|
||||
"""Verify unknown camera type returns None."""
|
||||
from backend.app.services.external_camera import capture_frame
|
||||
|
||||
result = await capture_frame("http://example.com", "unknown_type")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capture_frame_valid_types(self):
|
||||
"""Verify valid camera types are accepted (they may fail but shouldn't error on type)."""
|
||||
from backend.app.services.external_camera import capture_frame
|
||||
|
||||
# These will fail to connect but shouldn't raise type errors
|
||||
for camera_type in ["mjpeg", "rtsp", "snapshot"]:
|
||||
# Use a non-routable IP to fail fast
|
||||
result = await capture_frame("http://192.0.2.1/test", camera_type, timeout=1)
|
||||
# Should return None (failed connection) not raise exception
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestRtspUrlHandling:
|
||||
"""Tests for RTSP/RTSPS URL handling."""
|
||||
|
||||
def test_rtsps_url_detection(self):
|
||||
"""Verify rtsps:// and rtsp:// URL schemes are distinct."""
|
||||
url_rtsps = "rtsps://user:pass@192.168.1.1:554/stream"
|
||||
url_rtsp = "rtsp://user:pass@192.168.1.1:554/stream"
|
||||
|
||||
assert url_rtsps.startswith("rtsps://")
|
||||
assert not url_rtsp.startswith("rtsps://")
|
||||
assert url_rtsp.startswith("rtsp://")
|
||||
|
||||
def test_ffmpeg_handles_both_rtsp_and_rtsps(self):
|
||||
"""Verify ffmpeg command structure handles both URL schemes identically.
|
||||
|
||||
ffmpeg automatically handles TLS for rtsps:// URLs, so no special
|
||||
flags are needed - both URL schemes use the same command structure.
|
||||
"""
|
||||
# Both URL types should use the same basic ffmpeg options
|
||||
base_cmd = [
|
||||
"ffmpeg",
|
||||
"-rtsp_transport",
|
||||
"tcp",
|
||||
"-i",
|
||||
]
|
||||
|
||||
rtsp_url = "rtsp://user:pass@192.168.1.1:554/stream"
|
||||
rtsps_url = "rtsps://user:pass@192.168.1.1:554/stream"
|
||||
|
||||
# Command structure is identical for both
|
||||
cmd_rtsp = base_cmd + [rtsp_url]
|
||||
cmd_rtsps = base_cmd + [rtsps_url]
|
||||
|
||||
# Only the URL differs
|
||||
assert cmd_rtsp[:-1] == cmd_rtsps[:-1]
|
||||
assert cmd_rtsp[-1] != cmd_rtsps[-1]
|
||||
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
Tests for the layer timelapse service.
|
||||
|
||||
These tests cover session management and pure logic functions.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestTimelapseSessionManagement:
|
||||
"""Tests for timelapse session lifecycle."""
|
||||
|
||||
def test_start_session_creates_new_session(self):
|
||||
"""Verify start_session creates and registers a new session."""
|
||||
from backend.app.services.layer_timelapse import (
|
||||
_active_sessions,
|
||||
cancel_session,
|
||||
get_session,
|
||||
start_session,
|
||||
)
|
||||
|
||||
# Clear any existing sessions
|
||||
_active_sessions.clear()
|
||||
|
||||
with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
|
||||
mock_settings.base_dir = Path("/tmp/test_bambuddy")
|
||||
|
||||
session = start_session(
|
||||
printer_id=1,
|
||||
archive_id=100,
|
||||
url="http://camera.local/mjpeg",
|
||||
cam_type="mjpeg",
|
||||
)
|
||||
|
||||
assert session is not None
|
||||
assert session.printer_id == 1
|
||||
assert session.archive_id == 100
|
||||
assert session.camera_url == "http://camera.local/mjpeg"
|
||||
assert session.camera_type == "mjpeg"
|
||||
assert session.last_layer == -1
|
||||
assert session.frame_count == 0
|
||||
|
||||
# Session should be retrievable
|
||||
retrieved = get_session(1)
|
||||
assert retrieved is session
|
||||
|
||||
# Cleanup
|
||||
cancel_session(1)
|
||||
|
||||
def test_start_session_cancels_existing(self):
|
||||
"""Verify starting a new session cancels any existing session."""
|
||||
from backend.app.services.layer_timelapse import (
|
||||
_active_sessions,
|
||||
cancel_session,
|
||||
get_session,
|
||||
start_session,
|
||||
)
|
||||
|
||||
_active_sessions.clear()
|
||||
|
||||
with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
|
||||
mock_settings.base_dir = Path("/tmp/test_bambuddy")
|
||||
|
||||
# Start first session
|
||||
session1 = start_session(1, 100, "http://cam1/", "mjpeg")
|
||||
|
||||
# Mock cleanup to track if it was called
|
||||
session1.cleanup = MagicMock()
|
||||
|
||||
# Start second session for same printer
|
||||
session2 = start_session(1, 101, "http://cam2/", "rtsp")
|
||||
|
||||
# First session should be replaced
|
||||
current = get_session(1)
|
||||
assert current is session2
|
||||
assert current.archive_id == 101 # Verify it's the new session
|
||||
assert current.camera_url == "http://cam2/"
|
||||
|
||||
# First session's cleanup should have been called
|
||||
session1.cleanup.assert_called_once()
|
||||
|
||||
# Cleanup
|
||||
cancel_session(1)
|
||||
|
||||
def test_get_session_returns_none_for_unknown(self):
|
||||
"""Verify get_session returns None for unknown printer."""
|
||||
from backend.app.services.layer_timelapse import _active_sessions, get_session
|
||||
|
||||
_active_sessions.clear()
|
||||
|
||||
result = get_session(999)
|
||||
assert result is None
|
||||
|
||||
def test_cancel_session_removes_and_cleans_up(self):
|
||||
"""Verify cancel_session removes session and cleans up."""
|
||||
from backend.app.services.layer_timelapse import (
|
||||
_active_sessions,
|
||||
cancel_session,
|
||||
get_session,
|
||||
start_session,
|
||||
)
|
||||
|
||||
_active_sessions.clear()
|
||||
|
||||
with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
|
||||
mock_settings.base_dir = Path("/tmp/test_bambuddy")
|
||||
|
||||
session = start_session(1, 100, "http://cam/", "mjpeg")
|
||||
|
||||
# Mock cleanup to avoid filesystem operations
|
||||
session.cleanup = MagicMock()
|
||||
|
||||
cancel_session(1)
|
||||
|
||||
# Session should be removed
|
||||
assert get_session(1) is None
|
||||
# Cleanup should have been called
|
||||
session.cleanup.assert_called_once()
|
||||
|
||||
def test_cancel_nonexistent_session_is_safe(self):
|
||||
"""Verify canceling a non-existent session doesn't error."""
|
||||
from backend.app.services.layer_timelapse import _active_sessions, cancel_session
|
||||
|
||||
_active_sessions.clear()
|
||||
|
||||
# Should not raise
|
||||
cancel_session(999)
|
||||
|
||||
|
||||
class TestTimelapseSession:
|
||||
"""Tests for TimelapseSession class."""
|
||||
|
||||
def test_session_id_format(self):
|
||||
"""Verify session ID follows expected datetime format."""
|
||||
from backend.app.services.layer_timelapse import TimelapseSession, _active_sessions
|
||||
|
||||
_active_sessions.clear()
|
||||
|
||||
with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
|
||||
mock_settings.base_dir = Path("/tmp/test_bambuddy")
|
||||
|
||||
session = TimelapseSession(
|
||||
printer_id=1,
|
||||
archive_id=100,
|
||||
camera_url="http://test/",
|
||||
camera_type="mjpeg",
|
||||
)
|
||||
|
||||
# Session ID should be timestamp format YYYYMMDD_HHMMSS
|
||||
assert len(session.session_id) == 15
|
||||
assert session.session_id[8] == "_"
|
||||
|
||||
# Should be parseable as datetime
|
||||
try:
|
||||
datetime.strptime(session.session_id, "%Y%m%d_%H%M%S")
|
||||
except ValueError:
|
||||
pytest.fail("Session ID is not valid datetime format")
|
||||
|
||||
def test_frames_dir_path_structure(self):
|
||||
"""Verify frames directory path is structured correctly."""
|
||||
from backend.app.services.layer_timelapse import TimelapseSession
|
||||
|
||||
with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
|
||||
mock_settings.base_dir = Path("/data/bambuddy")
|
||||
|
||||
with patch.object(Path, "mkdir"): # Avoid creating real directories
|
||||
session = TimelapseSession(
|
||||
printer_id=42,
|
||||
archive_id=100,
|
||||
camera_url="http://test/",
|
||||
camera_type="mjpeg",
|
||||
)
|
||||
|
||||
expected_path = Path("/data/bambuddy/timelapse_frames/42") / session.session_id
|
||||
assert session.frames_dir == expected_path
|
||||
|
||||
|
||||
class TestLayerChangeLogic:
|
||||
"""Tests for layer change capture logic."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capture_layer_only_on_increase(self):
|
||||
"""Verify frames are only captured when layer increases."""
|
||||
from backend.app.services.layer_timelapse import TimelapseSession
|
||||
|
||||
with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
|
||||
mock_settings.base_dir = Path("/tmp/test")
|
||||
|
||||
with patch.object(Path, "mkdir"):
|
||||
session = TimelapseSession(1, 100, "http://test/", "mjpeg")
|
||||
|
||||
# Mock capture_frame to return data
|
||||
with patch(
|
||||
"backend.app.services.layer_timelapse.capture_frame", new_callable=AsyncMock
|
||||
) as mock_capture:
|
||||
mock_capture.return_value = b"\xff\xd8test\xff\xd9"
|
||||
|
||||
with patch.object(Path, "write_bytes"):
|
||||
# First layer should capture
|
||||
result = await session.capture_layer(1)
|
||||
assert result is True
|
||||
assert session.last_layer == 1
|
||||
assert session.frame_count == 1
|
||||
|
||||
# Same layer should NOT capture
|
||||
result = await session.capture_layer(1)
|
||||
assert result is False
|
||||
assert session.frame_count == 1
|
||||
|
||||
# Lower layer should NOT capture
|
||||
result = await session.capture_layer(0)
|
||||
assert result is False
|
||||
assert session.frame_count == 1
|
||||
|
||||
# Higher layer should capture
|
||||
result = await session.capture_layer(5)
|
||||
assert result is True
|
||||
assert session.last_layer == 5
|
||||
assert session.frame_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capture_layer_handles_failed_capture(self):
|
||||
"""Verify failed capture returns False but updates layer."""
|
||||
from backend.app.services.layer_timelapse import TimelapseSession
|
||||
|
||||
with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
|
||||
mock_settings.base_dir = Path("/tmp/test")
|
||||
|
||||
with patch.object(Path, "mkdir"):
|
||||
session = TimelapseSession(1, 100, "http://test/", "mjpeg")
|
||||
|
||||
# Mock capture_frame to return None (failure)
|
||||
with patch(
|
||||
"backend.app.services.layer_timelapse.capture_frame", new_callable=AsyncMock
|
||||
) as mock_capture:
|
||||
mock_capture.return_value = None
|
||||
|
||||
result = await session.capture_layer(1)
|
||||
|
||||
assert result is False
|
||||
assert session.last_layer == 1 # Layer is still updated
|
||||
assert session.frame_count == 0 # But frame count not incremented
|
||||
|
||||
|
||||
class TestOnLayerChange:
|
||||
"""Tests for the on_layer_change callback."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_layer_change_captures_when_session_exists(self):
|
||||
"""Verify on_layer_change triggers capture when session exists."""
|
||||
from backend.app.services.layer_timelapse import (
|
||||
_active_sessions,
|
||||
cancel_session,
|
||||
on_layer_change,
|
||||
start_session,
|
||||
)
|
||||
|
||||
_active_sessions.clear()
|
||||
|
||||
with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
|
||||
mock_settings.base_dir = Path("/tmp/test")
|
||||
|
||||
with patch.object(Path, "mkdir"):
|
||||
session = start_session(1, 100, "http://test/", "mjpeg")
|
||||
|
||||
with patch.object(session, "capture_layer", new_callable=AsyncMock) as mock_capture:
|
||||
mock_capture.return_value = True
|
||||
|
||||
await on_layer_change(1, 5)
|
||||
|
||||
mock_capture.assert_called_once_with(5)
|
||||
|
||||
cancel_session(1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_layer_change_does_nothing_without_session(self):
|
||||
"""Verify on_layer_change is safe when no session exists."""
|
||||
from backend.app.services.layer_timelapse import _active_sessions, on_layer_change
|
||||
|
||||
_active_sessions.clear()
|
||||
|
||||
# Should not raise
|
||||
await on_layer_change(999, 10)
|
||||
|
||||
|
||||
class TestGetActiveSessions:
|
||||
"""Tests for get_active_sessions."""
|
||||
|
||||
def test_get_active_sessions_returns_copy(self):
|
||||
"""Verify get_active_sessions returns a copy, not the original dict."""
|
||||
from backend.app.services.layer_timelapse import (
|
||||
_active_sessions,
|
||||
cancel_session,
|
||||
get_active_sessions,
|
||||
start_session,
|
||||
)
|
||||
|
||||
_active_sessions.clear()
|
||||
|
||||
with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
|
||||
mock_settings.base_dir = Path("/tmp/test")
|
||||
|
||||
with patch.object(Path, "mkdir"):
|
||||
start_session(1, 100, "http://test/", "mjpeg")
|
||||
|
||||
sessions = get_active_sessions()
|
||||
|
||||
# Should be a copy
|
||||
assert sessions is not _active_sessions
|
||||
assert 1 in sessions
|
||||
|
||||
# Modifying copy shouldn't affect original
|
||||
sessions.clear()
|
||||
assert 1 in _active_sessions
|
||||
|
||||
cancel_session(1)
|
||||
@@ -65,6 +65,9 @@ export interface Printer {
|
||||
nozzle_count: number; // 1 or 2, auto-detected from MQTT
|
||||
is_active: boolean;
|
||||
auto_archive: boolean;
|
||||
external_camera_url: string | null;
|
||||
external_camera_type: string | null; // "mjpeg", "rtsp", "snapshot"
|
||||
external_camera_enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -209,6 +212,9 @@ export interface PrinterCreate {
|
||||
model?: string;
|
||||
location?: string;
|
||||
auto_archive?: boolean;
|
||||
external_camera_url?: string | null;
|
||||
external_camera_type?: string | null;
|
||||
external_camera_enabled?: boolean;
|
||||
}
|
||||
|
||||
// Archive types
|
||||
@@ -1548,6 +1554,11 @@ export const api = {
|
||||
request<{ connected: boolean }>(`/printers/${id}/disconnect`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
testExternalCamera: (printerId: number, url: string, cameraType: string) =>
|
||||
request<{ success: boolean; error?: string; resolution?: string }>(
|
||||
`/printers/${printerId}/camera/external/test?url=${encodeURIComponent(url)}&camera_type=${encodeURIComponent(cameraType)}`,
|
||||
{ method: 'POST' }
|
||||
),
|
||||
|
||||
// Print Control
|
||||
stopPrint: (printerId: number) =>
|
||||
|
||||
@@ -95,6 +95,10 @@ export function SettingsPage() {
|
||||
const [haTestResult, setHaTestResult] = useState<{ success: boolean; message: string | null; error: string | null } | null>(null);
|
||||
const [haTestLoading, setHaTestLoading] = useState(false);
|
||||
|
||||
// External camera test state
|
||||
const [extCameraTestResults, setExtCameraTestResults] = useState<Record<number, { success: boolean; error?: string; resolution?: string } | null>>({});
|
||||
const [extCameraTestLoading, setExtCameraTestLoading] = useState<Record<number, boolean>>({});
|
||||
|
||||
const handleDefaultViewChange = (path: string) => {
|
||||
setDefaultViewState(path);
|
||||
setDefaultView(path);
|
||||
@@ -366,6 +370,18 @@ export function SettingsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const updatePrinterMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<{ external_camera_url: string | null; external_camera_type: string | null; external_camera_enabled: boolean }> }) =>
|
||||
api.updatePrinter(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['printers'] });
|
||||
showToast('Camera settings saved', 'success');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(`Failed to update printer: ${error.message}`, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
// Debounced auto-save when localSettings change
|
||||
useEffect(() => {
|
||||
// Skip if initial load or no settings
|
||||
@@ -489,6 +505,38 @@ export function SettingsPage() {
|
||||
setLocalSettings(prev => prev ? { ...prev, [key]: value } : null);
|
||||
}, []);
|
||||
|
||||
const handleTestExternalCamera = async (printerId: number, url: string, cameraType: string) => {
|
||||
if (!url) {
|
||||
showToast('Please enter a camera URL', 'error');
|
||||
return;
|
||||
}
|
||||
setExtCameraTestLoading(prev => ({ ...prev, [printerId]: true }));
|
||||
setExtCameraTestResults(prev => ({ ...prev, [printerId]: null }));
|
||||
try {
|
||||
const result = await api.testExternalCamera(printerId, url, cameraType);
|
||||
setExtCameraTestResults(prev => ({ ...prev, [printerId]: result }));
|
||||
if (result.success) {
|
||||
showToast(`Camera connected${result.resolution ? ` (${result.resolution})` : ''}`, 'success');
|
||||
} else {
|
||||
showToast(result.error || 'Connection failed', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Test failed';
|
||||
setExtCameraTestResults(prev => ({ ...prev, [printerId]: { success: false, error: message } }));
|
||||
showToast(message, 'error');
|
||||
} finally {
|
||||
setExtCameraTestLoading(prev => ({ ...prev, [printerId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdatePrinterCamera = (printerId: number, updates: { url?: string; type?: string; enabled?: boolean }) => {
|
||||
const data: Partial<{ external_camera_url: string | null; external_camera_type: string | null; external_camera_enabled: boolean }> = {};
|
||||
if (updates.url !== undefined) data.external_camera_url = updates.url || null;
|
||||
if (updates.type !== undefined) data.external_camera_type = updates.type || null;
|
||||
if (updates.enabled !== undefined) data.external_camera_enabled = updates.enabled;
|
||||
updatePrinterMutation.mutate({ id: printerId, data });
|
||||
};
|
||||
|
||||
if (isLoading || !localSettings) {
|
||||
return (
|
||||
<div className="p-4 md:p-8 flex justify-center">
|
||||
@@ -952,6 +1000,87 @@ export function SettingsPage() {
|
||||
: 'Camera opens in a separate browser window'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* External Cameras Section */}
|
||||
<div className="border-t border-bambu-dark-tertiary pt-4 mt-4">
|
||||
<h3 className="text-sm font-medium text-white mb-2">External Cameras</h3>
|
||||
<p className="text-xs text-bambu-gray mb-3">
|
||||
Configure network cameras to replace the built-in printer camera. Supports MJPEG streams, RTSP, and HTTP snapshots. When enabled, the external camera is used for live view and finish photos.
|
||||
</p>
|
||||
|
||||
{printers && printers.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{printers.map(printer => (
|
||||
<div key={printer.id} className="p-3 bg-bambu-dark rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-white font-medium text-sm">{printer.name}</span>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={printer.external_camera_enabled}
|
||||
onChange={(e) => handleUpdatePrinterCamera(printer.id, { enabled: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 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:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{printer.external_camera_enabled && (
|
||||
<div className="space-y-2 mt-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Camera URL (rtsp://... or http://...)"
|
||||
value={printer.external_camera_url || ''}
|
||||
onChange={(e) => handleUpdatePrinterCamera(printer.id, { url: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={printer.external_camera_type || 'mjpeg'}
|
||||
onChange={(e) => handleUpdatePrinterCamera(printer.id, { type: e.target.value })}
|
||||
className="flex-1 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="mjpeg">MJPEG Stream</option>
|
||||
<option value="rtsp">RTSP Stream</option>
|
||||
<option value="snapshot">HTTP Snapshot</option>
|
||||
</select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleTestExternalCamera(printer.id, printer.external_camera_url || '', printer.external_camera_type || 'mjpeg')}
|
||||
disabled={extCameraTestLoading[printer.id] || !printer.external_camera_url}
|
||||
>
|
||||
{extCameraTestLoading[printer.id] ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
'Test'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{extCameraTestResults[printer.id] && (
|
||||
<div className={`text-xs flex items-center gap-1 ${extCameraTestResults[printer.id]?.success ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{extCameraTestResults[printer.id]?.success ? (
|
||||
<>
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Connected{extCameraTestResults[printer.id]?.resolution && ` (${extCameraTestResults[printer.id]?.resolution})`}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="w-3 h-3" />
|
||||
{extCameraTestResults[printer.id]?.error || 'Connection failed'}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-bambu-gray italic">No printers configured</p>
|
||||
)}
|
||||
</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
@@ -23,8 +23,8 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-BwLAOG3i.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DVKqcow3.css">
|
||||
<script type="module" crossorigin src="/assets/index-m8daLb5w.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BKSrBx0A.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user