diff --git a/backend/app/api/routes/camera.py b/backend/app/api/routes/camera.py index bba8be9bd..8b698b3de 100644 --- a/backend/app/api/routes/camera.py +++ b/backend/app/api/routes/camera.py @@ -13,9 +13,11 @@ from backend.app.core.database import get_db from backend.app.models.printer import Printer from backend.app.services.camera import ( capture_camera_frame, + generate_chamber_image_stream, get_camera_port, get_ffmpeg_path, - is_low_fps_model, + is_chamber_image_model, + read_next_chamber_frame, test_camera_connection, ) @@ -25,6 +27,9 @@ router = APIRouter(prefix="/printers", tags=["camera"]) # Track active ffmpeg processes for cleanup _active_streams: dict[str, asyncio.subprocess.Process] = {} +# Track active chamber image connections for cleanup +_active_chamber_streams: dict[str, tuple] = {} + # Store last frame for each printer (for photo capture from active stream) _last_frames: dict[int, bytes] = {} @@ -46,7 +51,96 @@ async def get_printer_or_404(printer_id: int, db: AsyncSession) -> Printer: return printer -async def generate_mjpeg_stream( +async def generate_chamber_mjpeg_stream( + ip_address: str, + access_code: str, + model: str | None, + fps: int = 5, + stream_id: str | None = None, + disconnect_event: asyncio.Event | None = None, + printer_id: int | None = None, +) -> AsyncGenerator[bytes, None]: + """Generate MJPEG stream from A1/P1 printer using chamber image protocol. + + This connects to port 6000 and reads JPEG frames using the Bambu binary protocol. + """ + logger.info(f"Starting chamber image stream for {ip_address} (stream_id={stream_id}, model={model})") + + connection = await generate_chamber_image_stream(ip_address, access_code, fps) + if connection is None: + logger.error(f"Failed to connect to chamber image stream for {ip_address}") + yield ( + b"--frame\r\n" + b"Content-Type: text/plain\r\n\r\n" + b"Error: Camera connection failed. Check printer is on and camera is enabled.\r\n" + ) + return + + reader, writer = connection + + # Track active connection for cleanup + if stream_id: + _active_chamber_streams[stream_id] = (reader, writer) + + try: + frame_interval = 1.0 / fps if fps > 0 else 0.2 + last_frame_time = 0.0 + + while True: + # Check if client disconnected + if disconnect_event and disconnect_event.is_set(): + logger.info(f"Client disconnected, stopping chamber stream {stream_id}") + break + + # Read next frame + frame = await read_next_chamber_frame(reader, timeout=30.0) + if frame is None: + logger.warning(f"Chamber image stream ended for {stream_id}") + break + + # Save frame to buffer for photo capture + if printer_id is not None: + _last_frames[printer_id] = frame + + # Rate limiting - skip frames if needed to maintain target FPS + current_time = asyncio.get_event_loop().time() + if current_time - last_frame_time < frame_interval: + continue + last_frame_time = current_time + + # Yield frame in MJPEG format + yield ( + 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" + ) + + except asyncio.CancelledError: + logger.info(f"Chamber image stream cancelled (stream_id={stream_id})") + except GeneratorExit: + logger.info(f"Chamber image stream generator exit (stream_id={stream_id})") + except Exception as e: + logger.exception(f"Chamber image stream error: {e}") + finally: + # Remove from active streams + if stream_id and stream_id in _active_chamber_streams: + del _active_chamber_streams[stream_id] + + # Clean up frame buffer + if printer_id is not None and printer_id in _last_frames: + del _last_frames[printer_id] + + # Close the connection + try: + writer.close() + await writer.wait_closed() + except Exception: + pass + logger.info(f"Chamber image stream stopped for {ip_address} (stream_id={stream_id})") + + +async def generate_rtsp_mjpeg_stream( ip_address: str, access_code: str, model: str | None, @@ -55,9 +149,9 @@ async def generate_mjpeg_stream( disconnect_event: asyncio.Event | None = None, printer_id: int | None = None, ) -> AsyncGenerator[bytes, None]: - """Generate MJPEG stream from printer camera using ffmpeg. + """Generate MJPEG stream from printer camera using ffmpeg/RTSP. - This captures frames continuously and yields them in MJPEG format. + This is for X1/H2/P2 models that support RTSP streaming. """ ffmpeg = get_ffmpeg_path() if not ffmpeg: @@ -67,11 +161,6 @@ async def generate_mjpeg_stream( port = get_camera_port(model) camera_url = f"rtsps://bblp:{access_code}@{ip_address}:{port}/streaming/live/1" - low_fps = is_low_fps_model(model) - - # For A1/P1 models, use lower FPS and longer timeouts - # These models have more limited camera streaming capability - effective_fps = min(fps, 5) if low_fps else fps # ffmpeg command to output MJPEG stream to stdout # -rtsp_transport tcp: Use TCP for reliability @@ -85,39 +174,19 @@ async def generate_mjpeg_stream( "tcp", "-rtsp_flags", "prefer_tcp", + "-i", + camera_url, + "-f", + "mjpeg", + "-q:v", + "5", + "-r", + str(fps), + "-an", # No audio + "-", # Output to stdout ] - # Add longer timeouts for A1/P1 models which may be slower to respond - if low_fps: - cmd.extend( - [ - "-timeout", - "10000000", # 10 seconds in microseconds (replaces deprecated -stimeout) - "-analyzeduration", - "10000000", # Longer analysis time - "-probesize", - "5000000", # Larger probe size - ] - ) - - cmd.extend( - [ - "-i", - camera_url, - "-f", - "mjpeg", - "-q:v", - "5", - "-r", - str(effective_fps), - "-an", # No audio - "-", # Output to stdout - ] - ) - - logger.info(f"Starting camera stream for {ip_address} (stream_id={stream_id}, model={model}, fps={effective_fps})") - if low_fps: - logger.info(f"Using extended timeouts for {model} camera") + logger.info(f"Starting RTSP camera stream for {ip_address} (stream_id={stream_id}, model={model}, fps={fps})") logger.debug(f"ffmpeg command: {ffmpeg} ... (url hidden)") process = None @@ -133,9 +202,7 @@ async def generate_mjpeg_stream( _active_streams[stream_id] = process # Give ffmpeg a moment to start and check for immediate failures - # A1/P1 models may need longer to establish connection - startup_wait = 2.0 if low_fps else 0.5 - await asyncio.sleep(startup_wait) + await asyncio.sleep(0.5) if process.returncode is not None: stderr = await process.stderr.read() logger.error(f"ffmpeg failed immediately: {stderr.decode()}") @@ -160,9 +227,7 @@ async def generate_mjpeg_stream( try: # Read chunk from ffmpeg - # A1/P1 models may have longer gaps between frames - read_timeout = 30.0 if low_fps else 10.0 - chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=read_timeout) + chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=10.0) if not chunk: logger.warning("Camera stream ended (no more data)") @@ -260,6 +325,10 @@ async def camera_stream( This endpoint returns a multipart MJPEG stream that can be used directly in an tag or video player. + Uses the appropriate protocol based on printer model: + - A1/P1: Chamber image protocol (port 6000) + - X1/H2/P2: RTSP via ffmpeg (port 322) + Args: printer_id: Printer ID fps: Target frames per second (default: 10, max: 30) @@ -268,8 +337,11 @@ async def camera_stream( printer = await get_printer_or_404(printer_id, db) - # Validate FPS - fps = min(max(fps, 1), 30) + # Validate FPS - A1/P1 models max out at ~5 FPS + if is_chamber_image_model(printer.model): + fps = min(max(fps, 1), 5) + else: + fps = min(max(fps, 1), 30) # Generate unique stream ID for tracking stream_id = f"{printer_id}-{uuid.uuid4().hex[:8]}" @@ -277,10 +349,18 @@ async def camera_stream( # Create disconnect event that will be set when client disconnects disconnect_event = asyncio.Event() + # Choose the appropriate stream generator based on model + if is_chamber_image_model(printer.model): + stream_generator = generate_chamber_mjpeg_stream + logger.info(f"Using chamber image protocol for {printer.model}") + else: + stream_generator = generate_rtsp_mjpeg_stream + logger.info(f"Using RTSP protocol for {printer.model}") + async def stream_with_disconnect_check(): """Wrapper generator that monitors for client disconnect.""" try: - async for chunk in generate_mjpeg_stream( + async for chunk in stream_generator( ip_address=printer.ip_address, access_code=printer.access_code, model=printer.model, @@ -325,6 +405,8 @@ async def stop_camera_stream(printer_id: int): Accepts both GET and POST (POST for sendBeacon compatibility). """ stopped = 0 + + # Stop ffmpeg/RTSP streams to_remove = [] for stream_id, process in list(_active_streams.items()): if stream_id.startswith(f"{printer_id}-"): @@ -340,9 +422,22 @@ async def stop_camera_stream(printer_id: int): for stream_id in to_remove: _active_streams.pop(stream_id, None) - logger.info( - f"Stopped {stopped} camera stream(s) for printer {printer_id}, active streams remaining: {list(_active_streams.keys())}" - ) + # Stop chamber image streams + to_remove_chamber = [] + for stream_id, (_reader, writer) in list(_active_chamber_streams.items()): + if stream_id.startswith(f"{printer_id}-"): + to_remove_chamber.append(stream_id) + try: + writer.close() + stopped += 1 + logger.info(f"Closed chamber image connection for stream {stream_id}") + except Exception as e: + logger.warning(f"Error stopping chamber stream {stream_id}: {e}") + + for stream_id in to_remove_chamber: + _active_chamber_streams.pop(stream_id, None) + + logger.info(f"Stopped {stopped} camera stream(s) for printer {printer_id}") return {"stopped": stopped} @@ -374,7 +469,10 @@ async def camera_snapshot( ) if not success: - raise HTTPException(status_code=503, detail="Failed to capture camera frame. Is the printer powered on?") + raise HTTPException( + status_code=503, + detail="Failed to capture camera frame. Ensure printer is on and camera is enabled.", + ) # Read and return the image with open(temp_path, "rb") as f: diff --git a/backend/app/services/camera.py b/backend/app/services/camera.py index 341e8f848..6669e2e5a 100644 --- a/backend/app/services/camera.py +++ b/backend/app/services/camera.py @@ -1,17 +1,25 @@ """Camera capture service for Bambu Lab printers. -Captures images from the printer's RTSPS camera stream using ffmpeg. +Supports two camera protocols: +- RTSP: Used by X1, X1C, X1E, H2C, H2D, H2DPRO, H2S, P2S (port 322) +- Chamber Image: Used by A1, A1MINI, P1P, P1S (port 6000, custom binary protocol) """ import asyncio import logging import shutil +import ssl +import struct import uuid from datetime import datetime from pathlib import Path logger = logging.getLogger(__name__) +# JPEG markers +JPEG_START = b"\xff\xd8" +JPEG_END = b"\xff\xd9" + # Cache the ffmpeg path after first lookup _ffmpeg_path: str | None = None @@ -53,39 +61,226 @@ def get_ffmpeg_path() -> str | None: return ffmpeg_path -def get_camera_port(model: str | None) -> int: - """Get the RTSPS port based on printer model. +def supports_rtsp(model: str | None) -> bool: + """Check if printer model supports RTSP camera streaming. - X1 and H2D series use port 322. - P1 and A1 series use port 6000. + RTSP supported: X1, X1C, X1E, H2C, H2D, H2DPRO, H2S, P2S + Chamber image only: A1, A1MINI, P1P, P1S """ 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 is_low_fps_model(model: str | None) -> bool: - """Check if printer model has limited camera FPS capability. - - A1 and P1 series have more limited camera streaming compared to X1. - They may need lower FPS and longer timeouts. - """ - if model: - model_upper = model.upper() - if model_upper.startswith(("A1", "P1")): + # These models support RTSP on port 322 + if model_upper.startswith(("X1", "H2", "P2")): return True + # A1/P1 and unknown models use chamber image protocol return False +def get_camera_port(model: str | None) -> int: + """Get the camera port based on printer model. + + X1/H2/P2 series use RTSP on port 322. + A1/P1 series use chamber image protocol on port 6000. + """ + if supports_rtsp(model): + return 322 + return 6000 + + +def is_chamber_image_model(model: str | None) -> bool: + """Check if printer uses chamber image protocol instead of RTSP. + + A1, A1MINI, P1P, P1S use the chamber image protocol on port 6000. + """ + return not supports_rtsp(model) + + def build_camera_url(ip_address: str, access_code: str, model: str | None) -> str: - """Build the RTSPS URL for the printer camera.""" + """Build the RTSPS URL for the printer camera (RTSP models only).""" port = get_camera_port(model) return f"rtsps://bblp:{access_code}@{ip_address}:{port}/streaming/live/1" +def _create_chamber_auth_payload(access_code: str) -> bytes: + """Create the 80-byte authentication payload for chamber image protocol. + + Format: + - Bytes 0-3: 0x40 0x00 0x00 0x00 (magic) + - Bytes 4-7: 0x00 0x30 0x00 0x00 (command) + - Bytes 8-15: zeros (padding) + - Bytes 16-47: username "bblp" (32 bytes, null-padded) + - Bytes 48-79: access code (32 bytes, null-padded) + """ + username = b"bblp" + access_code_bytes = access_code.encode("utf-8") + + # Build the 80-byte payload + payload = struct.pack( + " ssl.SSLContext: + """Create an SSL context for chamber image connection. + + Bambu printers use self-signed certificates, so we disable verification. + """ + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + + +async def read_chamber_image_frame( + ip_address: str, + access_code: str, + timeout: float = 10.0, +) -> bytes | None: + """Read a single JPEG frame from the chamber image protocol. + + This is used by A1/P1 printers which don't support RTSP. + + Args: + ip_address: Printer IP address + access_code: Printer access code + timeout: Connection timeout in seconds + + Returns: + JPEG image data or None if failed + """ + port = 6000 + ssl_context = _create_ssl_context() + + try: + # Connect with SSL + reader, writer = await asyncio.wait_for( + asyncio.open_connection(ip_address, port, ssl=ssl_context), + timeout=timeout, + ) + + try: + # Send authentication payload + auth_payload = _create_chamber_auth_payload(access_code) + writer.write(auth_payload) + await writer.drain() + + # Read the 16-byte header + header = await asyncio.wait_for(reader.readexactly(16), timeout=timeout) + if len(header) < 16: + logger.error("Chamber image: incomplete header received") + return None + + # Parse payload size from header (little-endian uint32 at offset 0) + payload_size = struct.unpack(" 10_000_000: # Sanity check: max 10MB + logger.error(f"Chamber image: invalid payload size {payload_size}") + return None + + # Read the JPEG data + jpeg_data = await asyncio.wait_for( + reader.readexactly(payload_size), + timeout=timeout, + ) + + # Validate JPEG markers + if not jpeg_data.startswith(JPEG_START): + logger.error("Chamber image: data is not a valid JPEG (missing start marker)") + return None + + if not jpeg_data.endswith(JPEG_END): + logger.warning("Chamber image: JPEG missing end marker, may be truncated") + + logger.debug(f"Chamber image: received {len(jpeg_data)} bytes") + return jpeg_data + + finally: + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + + except TimeoutError: + logger.error(f"Chamber image: connection timeout to {ip_address}:{port}") + return None + except ConnectionRefusedError: + logger.error(f"Chamber image: connection refused by {ip_address}:{port}") + return None + except Exception as e: + logger.exception(f"Chamber image: error connecting to {ip_address}:{port}: {e}") + return None + + +async def generate_chamber_image_stream( + ip_address: str, + access_code: str, + fps: int = 5, +) -> asyncio.StreamReader | None: + """Create a persistent connection for streaming chamber images. + + Returns a connected reader or None if connection failed. + """ + port = 6000 + ssl_context = _create_ssl_context() + + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(ip_address, port, ssl=ssl_context), + timeout=10.0, + ) + + # Send authentication payload + auth_payload = _create_chamber_auth_payload(access_code) + writer.write(auth_payload) + await writer.drain() + + logger.info(f"Chamber image: connected to {ip_address}:{port}") + return reader, writer + + except Exception as e: + logger.error(f"Chamber image: failed to connect to {ip_address}:{port}: {e}") + return None + + +async def read_next_chamber_frame(reader: asyncio.StreamReader, timeout: float = 10.0) -> bytes | None: + """Read the next JPEG frame from an established chamber image connection.""" + try: + # Read the 16-byte header + header = await asyncio.wait_for(reader.readexactly(16), timeout=timeout) + + # Parse payload size from header (little-endian uint32 at offset 0) + payload_size = struct.unpack(" 10_000_000: + logger.error(f"Chamber image: invalid payload size {payload_size}") + return None + + # Read the JPEG data + jpeg_data = await asyncio.wait_for( + reader.readexactly(payload_size), + timeout=timeout, + ) + + return jpeg_data + + except asyncio.IncompleteReadError: + logger.warning("Chamber image: connection closed by printer") + return None + except TimeoutError: + logger.warning("Chamber image: read timeout") + return None + except Exception as e: + logger.error(f"Chamber image: error reading frame: {e}") + return None + + async def capture_camera_frame( ip_address: str, access_code: str, @@ -95,6 +290,10 @@ async def capture_camera_frame( ) -> bool: """Capture a single frame from the printer's camera stream. + Uses the appropriate protocol based on printer model: + - A1/P1: Chamber image protocol (port 6000) + - X1/H2/P2: RTSP via ffmpeg (port 322) + Args: ip_address: Printer IP address access_code: Printer access code @@ -105,23 +304,33 @@ async def capture_camera_frame( 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) + # Use chamber image protocol for A1/P1 models + if is_chamber_image_model(model): + logger.info(f"Capturing camera frame from {ip_address} using chamber image protocol (model: {model})") + jpeg_data = await read_chamber_image_frame(ip_address, access_code, timeout=float(timeout)) + if jpeg_data: + try: + with open(output_path, "wb") as f: + f.write(jpeg_data) + logger.info(f"Successfully captured camera frame: {output_path}") + return True + except Exception as e: + logger.error(f"Failed to write camera frame: {e}") + return False + return False + + # Use RTSP/ffmpeg for X1/H2/P2 models + camera_url = build_camera_url(ip_address, access_code, model) + ffmpeg = get_ffmpeg_path() if not ffmpeg: logger.error("ffmpeg not found. Please install ffmpeg to enable camera capture.") return False # ffmpeg command to capture a single frame from RTSPS stream - # -rtsp_transport tcp: Use TCP for RTSP (more reliable) - # -rtsp_flags prefer_tcp: Prefer TCP for RTSP - # -y: Overwrite output file - # -frames:v 1: Capture only 1 frame - # -update 1: Allow writing single image without sequence pattern - # -q:v 2: High quality JPEG (1-31, lower is better) cmd = [ ffmpeg, "-y", # Overwrite output @@ -140,10 +349,9 @@ async def capture_camera_frame( str(output_path), ] - logger.info(f"Capturing camera frame from {ip_address} (model: {model})") + logger.info(f"Capturing camera frame from {ip_address} using RTSP (model: {model})") try: - # Run ffmpeg asynchronously with timeout process = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, @@ -248,7 +456,14 @@ async def test_camera_connection( if success: return {"success": True, "message": "Camera connection successful"} else: - return {"success": False, "error": "Failed to capture frame from camera"} + return { + "success": False, + "error": ( + "Failed to capture frame from camera. " + "Ensure the printer is powered on, camera is enabled, and LAN mode is active. " + "If running in Docker, try 'network_mode: host' in docker-compose.yml." + ), + } finally: # Clean up test file if test_path.exists(): diff --git a/docker-compose.yml b/docker-compose.yml index fd01379d2..2636da411 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,9 @@ services: bambuddy: build: . container_name: bambuddy + # OPTIONAL: Host network mode can help if you have camera or printer + # discovery issues. Docker's default bridge networking works in most setups. + # network_mode: host # Uncomment this and remove "ports:" below if needed ports: - "8000:8000" volumes: