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: