mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-07 06:31:22 +02:00
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:
@@ -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
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user