Browse Source

Key Discovery

  A1/P1 printers don't support RTSP - they use a custom chamber image protocol on port 6000:
  - SSL/TLS connection
  - 80-byte binary auth payload (magic + "bblp" + access code)
  - 16-byte header with payload size + JPEG data

  Changes Made

  backend/app/services/camera.py

  - Added supports_rtsp() - detects X1/H2/P2 (RTSP) vs A1/P1 (chamber image)
  - Added is_chamber_image_model() - inverse check
  - Added _create_chamber_auth_payload() - builds 80-byte auth
  - Added _create_ssl_context() - SSL for self-signed certs
  - Added read_chamber_image_frame() - single frame capture
  - Added generate_chamber_image_stream() - persistent connection
  - Added read_next_chamber_frame() - read from stream
  - Updated capture_camera_frame() - uses correct protocol per model

  backend/app/api/routes/camera.py

  - Added generate_chamber_mjpeg_stream() - MJPEG from chamber protocol
  - Renamed generate_mjpeg_stream() → generate_rtsp_mjpeg_stream()
  - Updated camera_stream endpoint - chooses protocol based on model
  - Updated stop_camera_stream - cleans up both stream types
  - Added tracking for _active_chamber_streams

  Protocol Matrix

  | Model                       | Protocol      | Port |
  |-----------------------------|---------------|------|
  | X1, X1C, X1E, H2C, H2D, P2S | RTSP          | 322  |
  | A1, A1MINI, P1P, P1S        | Chamber Image | 6000 |
maziggy 5 months ago
parent
commit
11a6bb282a
3 changed files with 398 additions and 82 deletions
  1. 151 53
      backend/app/api/routes/camera.py
  2. 244 29
      backend/app/services/camera.py
  3. 3 0
      docker-compose.yml

+ 151 - 53
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 <img> 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:

+ 244 - 29
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
+        # 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_low_fps_model(model: str | None) -> bool:
-    """Check if printer model has limited camera FPS capability.
+def is_chamber_image_model(model: str | None) -> bool:
+    """Check if printer uses chamber image protocol instead of RTSP.
 
-    A1 and P1 series have more limited camera streaming compared to X1.
-    They may need lower FPS and longer timeouts.
+    A1, A1MINI, P1P, P1S use the chamber image protocol on port 6000.
     """
-    if model:
-        model_upper = model.upper()
-        if model_upper.startswith(("A1", "P1")):
-            return True
-    return False
+    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(
+        "<II8s32s32s",
+        0x40,  # Magic header
+        0x3000,  # Command
+        b"\x00" * 8,  # Padding
+        username.ljust(32, b"\x00"),  # Username padded to 32 bytes
+        access_code_bytes.ljust(32, b"\x00"),  # Access code padded to 32 bytes
+    )
+    return payload
+
+
+def _create_ssl_context() -> 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("<I", header[0:4])[0]
+
+            if payload_size == 0 or payload_size > 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("<I", header[0:4])[0]
+
+        if payload_size == 0 or payload_size > 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():

+ 3 - 0
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: