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 |
This commit is contained in:
maziggy
2025-12-25 07:44:53 +01:00
parent 01989b7182
commit 11a6bb282a
3 changed files with 401 additions and 85 deletions
+151 -53
View File
@@ -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:
+247 -32
View File
@@ -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(
"<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
View File
@@ -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: