|
|
@@ -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():
|