diff --git a/app/camera_detection.py b/app/camera_detection.py
new file mode 100644
index 0000000..8b36f95
--- /dev/null
+++ b/app/camera_detection.py
@@ -0,0 +1,404 @@
+import logging
+from typing import List, Dict, Any
+import traceback
+
+logger = logging.getLogger(__name__)
+
+
+def find_opencv_cameras_fallback() -> List[Dict[str, Any]]:
+ """
+ Find available OpenCV cameras using direct OpenCV detection.
+
+ Returns:
+ List of camera information dictionaries
+ """
+ try:
+ import cv2
+
+ logger.info("Searching for OpenCV cameras using direct method...")
+ cameras = []
+
+ # Test only first 5 indices to avoid spam
+ for index in range(5):
+ cap = cv2.VideoCapture(index)
+
+ if cap.isOpened():
+ # Get camera properties
+ width = cap.get(cv2.CAP_PROP_FRAME_WIDTH)
+ height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT)
+ fps = cap.get(cv2.CAP_PROP_FPS)
+ backend = cap.get(cv2.CAP_PROP_BACKEND)
+
+ # Give camera time to initialize (especially for built-in cameras)
+ import time
+ time.sleep(0.3)
+
+ # Try multiple captures for built-in cameras that may need warmup
+ ret, frame = None, None
+ for attempt in range(3):
+ ret, frame = cap.read()
+ if ret and frame is not None and frame.sum() > 0: # Check if not all black
+ break
+ time.sleep(0.2)
+
+ if ret and frame is not None:
+ # Capture a preview image immediately
+ import base64
+ _, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 70])
+ preview_image = base64.b64encode(buffer).decode('utf-8')
+
+ camera_info = {
+ "name": f"Camera {index}",
+ "type": "OpenCV",
+ "id": index,
+ "backend_api": get_backend_name_fallback(backend),
+ "default_stream_profile": {
+ "width": int(width) if width > 0 else 640,
+ "height": int(height) if height > 0 else 480,
+ "fps": float(fps) if fps > 0 else 30.0,
+ "format": "RGB"
+ },
+ "preview_image": preview_image # Add preview image
+ }
+ cameras.append(camera_info)
+ logger.info(f"Found working camera at index {index} with preview")
+
+ cap.release()
+
+ logger.info(f"Found {len(cameras)} OpenCV cameras using direct method")
+ return cameras
+
+ except ImportError:
+ logger.error("OpenCV not available for camera detection")
+ return []
+ except Exception as e:
+ logger.error(f"Error in direct camera detection: {e}")
+ return []
+
+
+def get_backend_name_fallback(backend_id: float) -> str:
+ """Convert OpenCV backend ID to readable name"""
+ backend_names = {
+ 0: "ANY",
+ 200: "V4L2",
+ 700: "AVFOUNDATION",
+ 1400: "DSHOW",
+ 1500: "GSTREAMER",
+ 1600: "FFMPEG"
+ }
+ return backend_names.get(int(backend_id), f"UNKNOWN_{int(backend_id)}")
+
+
+def find_opencv_cameras() -> List[Dict[str, Any]]:
+ """
+ Find available OpenCV cameras using direct detection (avoiding LeRobot spam).
+
+ Returns:
+ List of camera information dictionaries
+ """
+ # Force use of direct detection to avoid the 60-index spam from LeRobot
+ logger.info("Using direct OpenCV detection to avoid LeRobot spam...")
+ return find_opencv_cameras_fallback()
+
+
+def find_realsense_cameras() -> List[Dict[str, Any]]:
+ """
+ Find available RealSense cameras using LeRobot's camera detection.
+
+ Returns:
+ List of camera information dictionaries
+ """
+ try:
+ # Check if pyrealsense2 is properly available first
+ try:
+ import pyrealsense2 as rs
+ except ImportError:
+ logger.info("pyrealsense2 not installed - skipping RealSense camera detection")
+ return []
+ except Exception as e:
+ logger.info(f"pyrealsense2 not available: {e} - skipping RealSense camera detection")
+ return []
+
+ # Try to import LeRobot RealSense support
+ try:
+ from lerobot.common.cameras.realsense.camera_realsense import RealSenseCamera
+ except ImportError:
+ logger.info("LeRobot RealSense support not available - skipping RealSense camera detection")
+ return []
+
+ logger.info("Searching for RealSense cameras using LeRobot...")
+ realsense_cameras = RealSenseCamera.find_cameras()
+
+ logger.info(f"Found {len(realsense_cameras)} RealSense cameras")
+ return realsense_cameras
+
+ except Exception as e:
+ logger.info(f"RealSense camera detection skipped: {e}")
+ return []
+
+
+def find_all_cameras(camera_type_filter: str = None) -> List[Dict[str, Any]]:
+ """
+ Find all available cameras, optionally filtered by type.
+
+ Args:
+ camera_type_filter: Optional filter ("opencv", "realsense", or None for all)
+
+ Returns:
+ List of all available cameras matching the filter
+ """
+ all_cameras = []
+
+ if camera_type_filter is None or camera_type_filter.lower() == "opencv":
+ all_cameras.extend(find_opencv_cameras())
+
+ if camera_type_filter is None or camera_type_filter.lower() == "realsense":
+ all_cameras.extend(find_realsense_cameras())
+
+ return all_cameras
+
+
+def test_camera_configuration(camera_info: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Test a camera configuration to ensure it works.
+
+ Args:
+ camera_info: Camera information dictionary from find_cameras
+
+ Returns:
+ Test result with success status and details
+ """
+ try:
+ camera_type = camera_info.get("type", "").lower()
+ camera_id = camera_info.get("id")
+
+ if camera_type == "opencv":
+ from lerobot.common.cameras.opencv.camera_opencv import OpenCVCamera
+ from lerobot.common.cameras.opencv.configuration_opencv import OpenCVCameraConfig
+ from lerobot.common.cameras.configs import ColorMode
+
+ config = OpenCVCameraConfig(
+ index_or_path=camera_id,
+ color_mode=ColorMode.RGB,
+ )
+ camera = OpenCVCamera(config)
+
+ elif camera_type == "realsense":
+ from lerobot.common.cameras.realsense.camera_realsense import RealSenseCamera
+ from lerobot.common.cameras.realsense.configuration_realsense import RealSenseCameraConfig
+ from lerobot.common.cameras.configs import ColorMode
+
+ config = RealSenseCameraConfig(
+ serial_number_or_name=camera_id,
+ color_mode=ColorMode.RGB,
+ )
+ camera = RealSenseCamera(config)
+
+ else:
+ return {
+ "success": False,
+ "error": f"Unsupported camera type: {camera_type}",
+ "details": f"Camera type '{camera_type}' is not supported"
+ }
+
+ # Test connection
+ logger.info(f"Testing {camera_type} camera: {camera_id}")
+ camera.connect(warmup=False)
+
+ # Try to read a frame
+ frame = camera.read()
+
+ # Disconnect
+ camera.disconnect()
+
+ return {
+ "success": True,
+ "message": f"Camera {camera_id} ({camera_type}) tested successfully",
+ "frame_shape": frame.shape if frame is not None else None
+ }
+
+ except Exception as e:
+ logger.error(f"Camera test failed for {camera_info}: {e}")
+ logger.error(f"Traceback: {traceback.format_exc()}")
+ return {
+ "success": False,
+ "error": f"Camera test failed: {str(e)}",
+ "details": "Camera may be in use by another application or not properly configured"
+ }
+
+
+def create_camera_config_for_lerobot(camera_info: Dict[str, Any], custom_settings: Dict[str, Any] = None) -> Dict[str, Any]:
+ """
+ Create a camera configuration dictionary suitable for LeRobot robots.
+
+ Args:
+ camera_info: Camera information from find_cameras
+ custom_settings: Optional custom settings to override defaults
+
+ Returns:
+ Camera configuration dictionary for robot config
+ """
+ camera_type = camera_info.get("type", "").lower()
+ camera_id = camera_info.get("id")
+
+ # Get default stream profile
+ default_profile = camera_info.get("default_stream_profile", {})
+
+ if camera_type == "opencv":
+ config = {
+ "type": "opencv",
+ "index_or_path": camera_id,
+ "width": default_profile.get("width", 640),
+ "height": default_profile.get("height", 480),
+ "fps": default_profile.get("fps", 30),
+ }
+ elif camera_type == "realsense":
+ config = {
+ "type": "realsense",
+ "serial_number_or_name": camera_id,
+ "width": default_profile.get("width", 640),
+ "height": default_profile.get("height", 480),
+ "fps": default_profile.get("fps", 30),
+ "use_depth": False, # Default to color only
+ }
+ else:
+ # Fallback to opencv format
+ config = {
+ "type": "opencv",
+ "index_or_path": camera_id,
+ "width": 640,
+ "height": 480,
+ "fps": 30,
+ }
+
+ # Apply custom settings if provided
+ if custom_settings:
+ config.update(custom_settings)
+
+ return config
+
+
+def get_camera_summary() -> Dict[str, Any]:
+ """
+ Get a summary of all available cameras for display purposes.
+
+ Returns:
+ Summary dictionary with camera counts and basic info
+ """
+ try:
+ all_cameras = find_all_cameras()
+
+ opencv_count = sum(1 for cam in all_cameras if cam.get("type", "").lower() == "opencv")
+ realsense_count = sum(1 for cam in all_cameras if cam.get("type", "").lower() == "realsense")
+
+ return {
+ "total_cameras": len(all_cameras),
+ "opencv_cameras": opencv_count,
+ "realsense_cameras": realsense_count,
+ "cameras": all_cameras,
+ "success": True
+ }
+
+ except Exception as e:
+ logger.error(f"Error getting camera summary: {e}")
+ return {
+ "total_cameras": 0,
+ "opencv_cameras": 0,
+ "realsense_cameras": 0,
+ "cameras": [],
+ "success": False,
+ "error": str(e)
+ }
+
+
+def capture_image_from_camera(camera_info: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Capture an image from a specific camera and return as base64.
+
+ Args:
+ camera_info: Camera information dictionary from find_cameras
+
+ Returns:
+ Result with success status and base64 image data
+ """
+ try:
+ import cv2
+ import base64
+
+ camera_type = camera_info.get("type", "").lower()
+ camera_id = camera_info.get("id")
+
+ logger.info(f"Capturing image from {camera_type} camera: {camera_id}")
+ logger.info(f"Camera info received: {camera_info}")
+
+ # Validate camera_id
+ if camera_id is None or camera_id == "":
+ return {
+ "success": False,
+ "error": "Invalid camera ID",
+ "details": f"Received camera_id: {camera_id}"
+ }
+
+ # Ensure camera_id is an integer for OpenCV
+ try:
+ camera_index = int(camera_id)
+ except (ValueError, TypeError):
+ return {
+ "success": False,
+ "error": f"Camera ID must be an integer, got: {camera_id}",
+ "details": f"Camera info: {camera_info}"
+ }
+
+ # For now, use OpenCV directly since LeRobot camera modules may not be available
+ if camera_type == "opencv" or True: # Fallback to OpenCV for all cameras
+ cap = cv2.VideoCapture(camera_index)
+
+ if not cap.isOpened():
+ return {
+ "success": False,
+ "error": f"Cannot open camera at index {camera_index}",
+ "details": "Camera may be in use by another application"
+ }
+
+ # Give camera time to initialize (especially for built-in cameras)
+ import time
+ time.sleep(0.5)
+
+ # Try multiple captures for built-in cameras that may need warmup
+ ret, frame = None, None
+ for attempt in range(3):
+ ret, frame = cap.read()
+ if ret and frame is not None:
+ break
+ time.sleep(0.2)
+
+ cap.release()
+
+ if not ret or frame is None:
+ return {
+ "success": False,
+ "error": "Cannot capture frame from camera",
+ "details": "Camera opened but frame capture failed"
+ }
+
+ # Convert frame to JPEG
+ _, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 70])
+
+ # Convert to base64
+ image_base64 = base64.b64encode(buffer).decode('utf-8')
+
+ return {
+ "success": True,
+ "image_data": image_base64,
+ "message": f"Image captured from camera {camera_index}",
+ "frame_shape": frame.shape
+ }
+
+ except Exception as e:
+ logger.error(f"Image capture failed for {camera_info}: {e}")
+ logger.error(f"Traceback: {traceback.format_exc()}")
+ return {
+ "success": False,
+ "error": f"Image capture failed: {str(e)}",
+ "details": "Unexpected error during image capture"
+ }
\ No newline at end of file
diff --git a/app/config.py b/app/config.py
index 98a884b..d7d9695 100644
--- a/app/config.py
+++ b/app/config.py
@@ -27,6 +27,64 @@
LEADER_CONFIG_FILE = os.path.join(CONFIG_STORAGE_PATH, "leader_config.txt")
FOLLOWER_CONFIG_FILE = os.path.join(CONFIG_STORAGE_PATH, "follower_config.txt")
+# Define camera configuration storage path
+CAMERA_CONFIG_PATH = os.path.expanduser("~/.cache/huggingface/lerobot/camera_configs")
+CAMERA_CONFIG_FILE = os.path.join(CAMERA_CONFIG_PATH, "camera_config.json")
+
+# Define external URL configuration storage path
+EXTERNAL_URL_FILE = os.path.join(CONFIG_STORAGE_PATH, "external_url.txt")
+
+# Global variable to store current external URL
+_external_url = None
+
+def set_external_url(url: str):
+ """Set the external URL for QR code generation (ngrok, etc.)"""
+ global _external_url
+ _external_url = url.rstrip('/') if url else None
+
+ # Ensure directory exists
+ os.makedirs(CONFIG_STORAGE_PATH, exist_ok=True)
+
+ # Save to file for persistence
+ try:
+ with open(EXTERNAL_URL_FILE, 'w') as f:
+ f.write(url if url else '')
+ logger.info(f"External URL saved: {url}")
+ except Exception as e:
+ logger.error(f"Failed to save external URL: {e}")
+
+def get_external_url() -> str:
+ """Get the current external URL for QR code generation"""
+ global _external_url
+
+ # Return cached value if available
+ if _external_url is not None:
+ return _external_url
+
+ # Try to load from file
+ try:
+ if os.path.exists(EXTERNAL_URL_FILE):
+ with open(EXTERNAL_URL_FILE, 'r') as f:
+ url = f.read().strip()
+ _external_url = url if url else None
+ return _external_url
+ except Exception as e:
+ logger.error(f"Failed to load external URL: {e}")
+
+ return None
+
+def clear_external_url():
+ """Clear the external URL configuration"""
+ global _external_url
+ _external_url = None
+
+ try:
+ if os.path.exists(EXTERNAL_URL_FILE):
+ os.remove(EXTERNAL_URL_FILE)
+ logger.info("External URL configuration cleared")
+ except Exception as e:
+ logger.error(f"Failed to clear external URL: {e}")
+
def setup_calibration_files(leader_config: str, follower_config: str):
"""Setup calibration files in the correct locations for teleoperation and recording"""
# Extract config names from file paths (remove .json extension)
@@ -300,3 +358,91 @@ def get_default_robot_config(robot_type: str, available_configs: list):
return available_configs[0]
return None
+
+
+def save_camera_config(camera_config: dict):
+ """Save camera configuration to file"""
+ try:
+ import json
+
+ # Create the camera config directory if it doesn't exist
+ os.makedirs(CAMERA_CONFIG_PATH, exist_ok=True)
+
+ # Save the camera configuration as JSON
+ with open(CAMERA_CONFIG_FILE, 'w') as f:
+ json.dump(camera_config, f, indent=2)
+
+ logger.info(f"Saved camera configuration with {len(camera_config.get('cameras', {}))} cameras")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error saving camera configuration: {e}")
+ return False
+
+
+def get_saved_camera_config():
+ """Get the saved camera configuration from file"""
+ try:
+ import json
+
+ if os.path.exists(CAMERA_CONFIG_FILE):
+ with open(CAMERA_CONFIG_FILE, 'r') as f:
+ camera_config = json.load(f)
+ if camera_config:
+ logger.info(f"Found saved camera configuration with {len(camera_config.get('cameras', {}))} cameras")
+ return camera_config
+
+ logger.info("No saved camera configuration found")
+ return None
+
+ except Exception as e:
+ logger.error(f"Error reading saved camera configuration: {e}")
+ return None
+
+
+def get_default_camera_config():
+ """Get the default camera configuration, checking saved configs first"""
+ saved_config = get_saved_camera_config()
+ if saved_config:
+ return saved_config
+
+ # Return empty config as fallback
+ return {"cameras": {}}
+
+
+def update_camera_in_config(camera_name: str, camera_config: dict):
+ """Update a specific camera in the saved configuration"""
+ try:
+ # Get current config or create new one
+ full_config = get_saved_camera_config() or {"cameras": {}}
+
+ # Update the specific camera
+ full_config["cameras"][camera_name] = camera_config
+
+ # Save updated config
+ return save_camera_config(full_config)
+
+ except Exception as e:
+ logger.error(f"Error updating camera {camera_name} in config: {e}")
+ return False
+
+
+def remove_camera_from_config(camera_name: str):
+ """Remove a specific camera from the saved configuration"""
+ try:
+ # Get current config
+ full_config = get_saved_camera_config()
+ if not full_config or "cameras" not in full_config:
+ return True # Nothing to remove
+
+ # Remove the camera if it exists
+ if camera_name in full_config["cameras"]:
+ del full_config["cameras"][camera_name]
+ logger.info(f"Removed camera {camera_name} from configuration")
+
+ # Save updated config
+ return save_camera_config(full_config)
+
+ except Exception as e:
+ logger.error(f"Error removing camera {camera_name} from config: {e}")
+ return False
diff --git a/app/main.py b/app/main.py
index 668bb97..cdd7d67 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,11 +1,12 @@
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles
-from fastapi.responses import FileResponse
+from fastapi.responses import FileResponse, HTMLResponse
from fastapi.middleware.cors import CORSMiddleware
import os
import logging
import glob
import asyncio
+import traceback
from typing import List, Dict, Any
import threading
import queue
@@ -56,6 +57,17 @@
handle_replay_logs,
)
+# Import camera detection functionality
+from .camera_detection import (
+ find_all_cameras,
+ find_opencv_cameras,
+ find_realsense_cameras,
+ test_camera_configuration,
+ create_camera_config_for_lerobot,
+ get_camera_summary,
+ capture_image_from_camera
+)
+
# Set up logging
logging.basicConfig(level=logging.INFO)
@@ -98,6 +110,9 @@
save_robot_port,
get_saved_robot_port,
get_default_robot_port,
+ set_external_url,
+ get_external_url,
+ clear_external_url,
)
@@ -238,6 +253,42 @@ def stop_teleoperation():
return handle_stop_teleoperation()
+@app.post("/api/config/external-url")
+def configure_external_url(data: dict):
+ """Configure external URL for QR code generation (ngrok, tunneling, etc.)"""
+ try:
+ external_url = data.get("external_url", "").strip()
+
+ if external_url:
+ # Validate URL format
+ if not external_url.startswith(("http://", "https://")):
+ return {"success": False, "error": "URL must start with http:// or https://"}
+
+ set_external_url(external_url)
+ logger.info(f"โ
External URL configured: {external_url}")
+ return {"success": True, "external_url": external_url}
+ else:
+ # Clear external URL configuration
+ clear_external_url()
+ logger.info("โ
External URL configuration cleared")
+ return {"success": True, "external_url": None}
+
+ except Exception as e:
+ logger.error(f"โ Failed to configure external URL: {e}")
+ return {"success": False, "error": str(e)}
+
+
+@app.get("/api/config/external-url")
+def get_current_external_url():
+ """Get the current external URL configuration"""
+ try:
+ external_url = get_external_url()
+ return {"success": True, "external_url": external_url}
+ except Exception as e:
+ logger.error(f"โ Failed to get external URL: {e}")
+ return {"success": False, "error": str(e)}
+
+
@app.get("/teleoperation-status")
def teleoperation_status():
"""Get the current teleoperation status"""
@@ -262,6 +313,26 @@ def websocket_test():
return {"websocket_endpoint": "/ws/joint-data", "status": "available"}
+@app.get("/webrtc/status")
+def webrtc_status():
+ """Get WebRTC signaling server status"""
+ from .webrtc_signaling import signaling_server
+ try:
+ stats = signaling_server.get_stats()
+ return {
+ "status": "success",
+ "signaling_server": "active",
+ "stats": stats,
+ "endpoints": {
+ "signaling": "/ws/webrtc",
+ "status": "/webrtc/status"
+ }
+ }
+ except Exception as e:
+ logger.error(f"Error getting WebRTC status: {e}")
+ return {"status": "error", "message": str(e)}
+
+
@app.websocket("/ws/joint-data")
async def websocket_endpoint(websocket: WebSocket):
logger.info("๐ New WebSocket connection attempt")
@@ -294,6 +365,48 @@ async def websocket_endpoint(websocket: WebSocket):
logger.info("๐งน WebSocket connection cleaned up")
+@app.websocket("/ws/webrtc")
+async def webrtc_signaling_endpoint(websocket: WebSocket):
+ """WebRTC signaling server endpoint"""
+ from .webrtc_signaling import signaling_server
+ import json
+
+ logger.info("๐ New WebRTC signaling connection attempt")
+ connection_id = None
+
+ try:
+ connection_id = await signaling_server.connect(websocket)
+ logger.info(f"โ
WebRTC signaling connection established: {connection_id}")
+
+ while True:
+ try:
+ # Wait for signaling messages
+ data = await websocket.receive_text()
+ message = json.loads(data)
+
+ # Handle signaling message
+ await signaling_server.handle_message(connection_id, message)
+
+ except WebSocketDisconnect:
+ logger.info(f"๐ WebRTC signaling client disconnected: {connection_id}")
+ break
+ except json.JSONDecodeError as e:
+ logger.error(f"โ Invalid JSON received from {connection_id}: {e}")
+ await signaling_server._send_error(connection_id, "Invalid JSON format")
+ except Exception as e:
+ logger.error(f"โ Error processing WebRTC message from {connection_id}: {e}")
+ await signaling_server._send_error(connection_id, str(e))
+
+ except WebSocketDisconnect:
+ logger.info(f"๐ WebRTC signaling disconnected normally: {connection_id}")
+ except Exception as e:
+ logger.error(f"โ WebRTC signaling error: {e}")
+ finally:
+ if connection_id:
+ signaling_server.disconnect(connection_id)
+ logger.info(f"๐งน WebRTC signaling connection cleaned up: {connection_id}")
+
+
@app.post("/start-recording")
def start_recording(request: RecordingRequest):
"""Start a dataset recording session"""
@@ -532,33 +645,37 @@ def get_available_ports():
@app.get("/available-cameras")
def get_available_cameras():
- """Get all available cameras"""
+ """Get all available cameras using advanced detection"""
try:
- # Try to detect cameras using OpenCV
- import cv2
- cameras = []
-
- # Test up to 10 camera indices
- for i in range(10):
- cap = cv2.VideoCapture(i)
- if cap.isOpened():
- ret, frame = cap.read()
- if ret:
- cameras.append({
- "index": i,
- "name": f"Camera {i}",
- "available": True,
- "width": int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
- "height": int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
- "fps": int(cap.get(cv2.CAP_PROP_FPS)),
- })
- cap.release()
-
- return {"status": "success", "cameras": cameras}
- except ImportError:
- # OpenCV not available, return empty list
- logger.warning("OpenCV not available for camera detection")
- return {"status": "success", "cameras": []}
+ # Use the advanced camera detection with preview images and better error handling
+ cameras = find_all_cameras()
+
+ # Transform to maintain backward compatibility with the original format
+ compatible_cameras = []
+ for cam in cameras:
+ compatible_cam = {
+ "index": cam.get("id"),
+ "name": cam.get("name", f"Camera {cam.get('id')}"),
+ "available": True,
+ "type": cam.get("type", "OpenCV").lower(),
+ "backend_api": cam.get("backend_api", "UNKNOWN"),
+ }
+
+ # Add stream profile information
+ profile = cam.get("default_stream_profile", {})
+ compatible_cam.update({
+ "width": profile.get("width", 640),
+ "height": profile.get("height", 480),
+ "fps": profile.get("fps", 30),
+ })
+
+ # Add preview image if available
+ if "preview_image" in cam:
+ compatible_cam["preview_image"] = cam["preview_image"]
+
+ compatible_cameras.append(compatible_cam)
+
+ return {"status": "success", "cameras": compatible_cameras}
except Exception as e:
logger.error(f"Error detecting cameras: {e}")
return {"status": "error", "message": str(e), "cameras": []}
@@ -665,6 +782,1185 @@ def get_robot_config(robot_type: str, available_configs: str = ""):
return {"status": "error", "message": str(e)}
+# Camera detection endpoints
+@app.get("/cameras/detect")
+def detect_cameras(camera_type: str = None):
+ """Detect available cameras, optionally filtered by type (opencv/realsense)"""
+ try:
+ logger.info(f"Detecting cameras, filter: {camera_type}")
+ cameras = find_all_cameras(camera_type_filter=camera_type)
+
+ return {
+ "status": "success",
+ "cameras": cameras,
+ "total_found": len(cameras),
+ "message": f"Found {len(cameras)} cameras"
+ }
+ except Exception as e:
+ logger.error(f"Error detecting cameras: {e}")
+ return {"status": "error", "message": str(e), "cameras": []}
+
+
+@app.get("/cameras/summary")
+def get_cameras_summary():
+ """Get a summary of all available cameras"""
+ try:
+ summary = get_camera_summary()
+ return {"status": "success", **summary}
+ except Exception as e:
+ logger.error(f"Error getting camera summary: {e}")
+ return {"status": "error", "message": str(e)}
+
+
+@app.post("/cameras/test")
+def test_camera(camera_info: dict):
+ """Test a specific camera configuration"""
+ try:
+ result = test_camera_configuration(camera_info)
+ return {"status": "success" if result["success"] else "error", **result}
+ except Exception as e:
+ logger.error(f"Error testing camera: {e}")
+ return {"status": "error", "message": str(e)}
+
+
+@app.post("/cameras/capture")
+def capture_camera_image(data: dict):
+ """Capture an image from a specific camera"""
+ try:
+ # Extract camera_info from the request data
+ camera_info = data.get("camera_info", {})
+ logger.info(f"Capture endpoint called with camera_info: {camera_info}")
+
+ if not camera_info:
+ return {"status": "error", "message": "camera_info is required"}
+
+ result = capture_image_from_camera(camera_info)
+ logger.info(f"Capture result: success={result.get('success')}, error={result.get('error', 'None')}")
+
+ if result.get("success"):
+ logger.info(f"Successfully captured image from camera {camera_info.get('id')}")
+ else:
+ logger.warning(f"Failed to capture from camera {camera_info.get('id')}: {result.get('error')}")
+
+ return {"status": "success" if result["success"] else "error", **result}
+ except Exception as e:
+ logger.error(f"Error capturing image: {e}")
+ logger.error(f"Traceback: {traceback.format_exc()}")
+ return {"status": "error", "message": str(e)}
+
+
+@app.post("/cameras/create-config")
+def create_camera_config(data: dict):
+ """Create a camera configuration for LeRobot"""
+ try:
+ camera_info = data.get("camera_info")
+ custom_settings = data.get("custom_settings", {})
+
+ if not camera_info:
+ return {"status": "error", "message": "camera_info is required"}
+
+ config = create_camera_config_for_lerobot(camera_info, custom_settings)
+
+ return {
+ "status": "success",
+ "camera_config": config,
+ "message": "Camera configuration created successfully"
+ }
+ except Exception as e:
+ logger.error(f"Error creating camera config: {e}")
+ return {"status": "error", "message": str(e)}
+
+
+@app.get("/cameras/config")
+def get_camera_config():
+ """Get the saved camera configuration"""
+ try:
+ camera_config = config.get_default_camera_config()
+ return {
+ "status": "success",
+ "camera_config": camera_config,
+ "message": "Camera configuration retrieved successfully"
+ }
+ except Exception as e:
+ logger.error(f"Error getting camera config: {e}")
+ return {"status": "error", "message": str(e)}
+
+
+@app.post("/cameras/config/save")
+def save_camera_config_endpoint(data: dict):
+ """Save camera configuration"""
+ try:
+ camera_config = data.get("camera_config")
+
+ if not camera_config:
+ return {"status": "error", "message": "camera_config is required"}
+
+ success = config.save_camera_config(camera_config)
+
+ if success:
+ return {
+ "status": "success",
+ "message": "Camera configuration saved successfully"
+ }
+ else:
+ return {"status": "error", "message": "Failed to save camera configuration"}
+
+ except Exception as e:
+ logger.error(f"Error saving camera config: {e}")
+ return {"status": "error", "message": str(e)}
+
+
+@app.post("/cameras/config/update")
+def update_camera_config_endpoint(data: dict):
+ """Update a specific camera in the configuration"""
+ try:
+ camera_name = data.get("camera_name")
+ camera_config = data.get("camera_config")
+
+ if not camera_name or not camera_config:
+ return {"status": "error", "message": "camera_name and camera_config are required"}
+
+ success = config.update_camera_in_config(camera_name, camera_config)
+
+ if success:
+ return {
+ "status": "success",
+ "message": f"Camera {camera_name} updated successfully"
+ }
+ else:
+ return {"status": "error", "message": f"Failed to update camera {camera_name}"}
+
+ except Exception as e:
+ logger.error(f"Error updating camera config: {e}")
+ return {"status": "error", "message": str(e)}
+
+
+@app.delete("/cameras/config/{camera_name}")
+def remove_camera_config_endpoint(camera_name: str):
+ """Remove a specific camera from the configuration"""
+ try:
+ success = config.remove_camera_from_config(camera_name)
+
+ if success:
+ return {
+ "status": "success",
+ "message": f"Camera {camera_name} removed successfully"
+ }
+ else:
+ return {"status": "error", "message": f"Failed to remove camera {camera_name}"}
+
+ except Exception as e:
+ logger.error(f"Error removing camera config: {e}")
+ return {"status": "error", "message": str(e)}
+
+
+@app.get("/remote_cam/{session_id}", response_class=HTMLResponse)
+async def external_camera_page(session_id: str):
+ """Serve the external camera page for WebRTC connection"""
+
+ # HTML page for external camera capture
+ html_content = f"""
+
+
+
+
+
+ External Camera - LeLab
+
+
+
+
+
+
+
+
+ Connecting to session...
+
+
+
+
+
+
+
+
+
+
+
+ Instructions:
+
โข Allow camera access when prompted
+
โข Tap "Start Camera" to begin streaming
+
โข Keep this page open while using the robot
+
+
+
+
+
+
+
+
+
+ """
+
+ return HTMLResponse(content=html_content)
+
+
+@app.get("/cameras/stream/{camera_identifier}")
+def stream_camera(camera_identifier: str):
+ """Stream a specific camera feed using hash or name"""
+ try:
+ from fastapi.responses import StreamingResponse
+ import cv2
+ import time
+ from . import config
+
+ logger.info(f"Starting camera stream for identifier: {camera_identifier}")
+
+ # Get camera configuration
+ camera_config = config.get_saved_camera_config()
+ if not camera_config or "cameras" not in camera_config:
+ logger.error("No camera configuration found")
+ return {"status": "error", "message": "No camera configuration found"}
+
+ # Find camera by hash or name for backward compatibility
+ camera_info = None
+ camera_name = None
+
+ # First try to find by exact name match (backward compatibility)
+ if camera_identifier in camera_config["cameras"]:
+ camera_info = camera_config["cameras"][camera_identifier]
+ camera_name = camera_identifier
+ logger.info(f"Found camera by name: {camera_name}")
+ else:
+ # Try to find by hash (new robust system)
+ for name, config_data in camera_config["cameras"].items():
+ if config_data.get("hash") == camera_identifier:
+ camera_info = config_data
+ camera_name = name
+ logger.info(f"Found camera by hash: {camera_identifier} -> {camera_name}")
+ break
+
+ if not camera_info:
+ logger.error(f"Camera '{camera_identifier}' not found in configuration")
+ available_cameras = list(camera_config["cameras"].keys())
+ logger.info(f"Available cameras: {available_cameras}")
+ return {"status": "error", "message": f"Camera '{camera_identifier}' not found in configuration"}
+
+ logger.info(f"Camera config for '{camera_name}': {camera_info}")
+
+ # CRITICAL: Use device_id as primary identifier for consistency with preview
+ device_id = camera_info.get("device_id")
+ camera_hash = camera_info.get("hash")
+ index_or_path = camera_info.get("index_or_path", 0)
+ logger.info(f"Robust camera identifiers - device_id: {device_id}, hash: {camera_hash}, fallback_index: {index_or_path}")
+
+ def generate_frames():
+ cap = None
+ try:
+ # ROBUST APPROACH: Use device_id enumeration to find correct OpenCV index
+ # This ensures the SAME camera is opened in both preview and streaming
+
+ if device_id and not device_id.startswith("fallback_"):
+ # Try to map device_id to current OpenCV index
+ logger.info(f"Attempting to map device_id {device_id[:16]}... to OpenCV index")
+
+ # Get current camera enumeration (same as frontend does)
+ import subprocess
+ import json
+
+ try:
+ # Use Python to enumerate cameras the same way frontend does
+ enum_script = '''
+import cv2
+import json
+cameras = []
+for i in range(10): # Check first 10 indices
+ cap = cv2.VideoCapture(i)
+ if cap.isOpened():
+ cameras.append({"index": i, "available": True})
+ cap.release()
+ else:
+ break
+print(json.dumps(cameras))
+'''
+ result = subprocess.run(['python3', '-c', enum_script],
+ capture_output=True, text=True, timeout=10)
+
+ if result.returncode == 0:
+ available_cameras = json.loads(result.stdout)
+ logger.info(f"Available camera indices: {[c['index'] for c in available_cameras]}")
+
+ # Try each available index to see which matches our device
+ camera_index = None
+ for cam_info in available_cameras:
+ test_index = cam_info["index"]
+ test_cap = cv2.VideoCapture(test_index)
+ if test_cap.isOpened():
+ # For now, use the fallback index from config
+ # In a more sophisticated version, we could try to match device properties
+ if test_index == index_or_path:
+ camera_index = test_index
+ logger.info(f"Matched device_id to OpenCV index {camera_index}")
+ test_cap.release()
+ break
+ test_cap.release()
+
+ if camera_index is None:
+ # Use the stored index as fallback
+ camera_index = index_or_path
+ logger.warning(f"Could not map device_id, using fallback index {camera_index}")
+ else:
+ camera_index = index_or_path
+ logger.warning(f"Camera enumeration failed, using fallback index {camera_index}")
+
+ except Exception as e:
+ camera_index = index_or_path
+ logger.warning(f"Error during camera mapping: {e}, using fallback index {camera_index}")
+
+ else:
+ # No device_id or fallback device_id, use stored index
+ camera_index = index_or_path
+ logger.info(f"Using stored camera index: {camera_index}")
+
+ logger.info(f"Opening camera at index {camera_index} for streaming (device_id: {device_id[:16] if device_id else 'none'}...)")
+ cap = cv2.VideoCapture(camera_index)
+ identifier_used = f"robust_index_{camera_index}"
+
+ if not cap.isOpened():
+ logger.error(f"Failed to open camera with identifier: {identifier_used}")
+ return
+
+ logger.info(f"Camera opened successfully with identifier: {identifier_used}")
+
+ # Set camera properties from config
+ width = camera_info.get("width", 640)
+ height = camera_info.get("height", 480)
+ fps = camera_info.get("fps", 30)
+
+ cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
+ cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
+ cap.set(cv2.CAP_PROP_FPS, fps)
+
+ logger.info(f"Camera settings: {width}x{height} @ {fps}fps")
+
+ # Add camera warmup
+ logger.info("Warming up camera...")
+ time.sleep(0.5)
+
+ # Try multiple captures for camera warmup
+ for attempt in range(3):
+ ret, test_frame = cap.read()
+ if ret and test_frame is not None and test_frame.sum() > 0:
+ logger.info(f"Camera warmed up successfully after {attempt + 1} attempts")
+ break
+ time.sleep(0.2)
+ else:
+ logger.error("Camera warmup failed - no valid frames received")
+ return
+
+ frame_count = 0
+ while True:
+ ret, frame = cap.read()
+ if not ret:
+ logger.warning("Failed to read frame from camera")
+ break
+
+ # Validate frame is not black/empty
+ if frame is None or frame.sum() == 0:
+ logger.warning("Received black/empty frame, skipping")
+ continue
+
+ # Encode frame as JPEG - CRITICAL FIX: This should happen for EVERY frame, not just every 30th
+ _, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
+ frame_bytes = buffer.tobytes()
+
+ # Yield frame in multipart format
+ yield (b'--frame\r\n'
+ b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')
+
+ # Log every 30 frames for debugging
+ if frame_count % 30 == 0:
+ logger.info(f"Streaming frame {frame_count} for {camera_name}")
+
+ frame_count += 1
+
+ # Small delay to control frame rate
+ time.sleep(1.0 / fps)
+
+ except Exception as e:
+ logger.error(f"Error in camera stream for {camera_name}: {e}")
+ import traceback
+ logger.error(f"Traceback: {traceback.format_exc()}")
+ finally:
+ if cap:
+ cap.release()
+ logger.info(f"Released camera {camera_name}")
+
+ return StreamingResponse(
+ generate_frames(),
+ media_type="multipart/x-mixed-replace; boundary=frame"
+ )
+
+ except Exception as e:
+ logger.error(f"Error setting up camera stream for {camera_name}: {e}")
+ return {"status": "error", "message": str(e)}
+
+
@app.on_event("shutdown")
async def shutdown_event():
"""Clean up resources when FastAPI shuts down"""
diff --git a/app/webrtc_signaling.py b/app/webrtc_signaling.py
new file mode 100644
index 0000000..20da024
--- /dev/null
+++ b/app/webrtc_signaling.py
@@ -0,0 +1,529 @@
+# WebRTC Signaling Server for Unified Camera System
+
+import json
+import logging
+import asyncio
+import socket
+from typing import Dict, Set, Optional, Any
+from fastapi import WebSocket, WebSocketDisconnect
+import uuid
+
+logger = logging.getLogger(__name__)
+
+def get_local_ip():
+ """Get the local IP address of this machine"""
+ try:
+ # Connect to a remote address to determine the local IP
+ # This doesn't actually send data, just determines the local IP used for routing
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ s.connect(("8.8.8.8", 80))
+ local_ip = s.getsockname()[0]
+ s.close()
+ return local_ip
+ except Exception as e:
+ logger.warning(f"Could not determine local IP, using localhost: {e}")
+ return "localhost"
+
+class WebRTCSignalingServer:
+ """WebRTC signaling server for camera streaming"""
+
+ def __init__(self):
+ # Active WebSocket connections
+ self.connections: Dict[str, WebSocket] = {}
+
+ # Camera sources registry
+ self.camera_sources: Dict[str, Dict[str, Any]] = {}
+
+ # Peer-to-peer mappings (for future remote cameras)
+ self.peer_mappings: Dict[str, str] = {}
+
+ # Connection metadata
+ self.connection_metadata: Dict[str, Dict[str, Any]] = {}
+
+ # External camera sessions (legacy: phone_sessions for backward compatibility)
+ self.external_sessions: Dict[str, Dict[str, Any]] = {}
+ self.phone_sessions: Dict[str, Dict[str, Any]] = {} # Legacy support
+
+ def _get_session(self, session_id: str) -> Dict[str, Any]:
+ """Get session from either external_sessions or legacy phone_sessions"""
+ return self.external_sessions.get(session_id) or self.phone_sessions.get(session_id)
+
+ def _set_session(self, session_id: str, session_data: Dict[str, Any]):
+ """Set session, preferring external_sessions for new sessions"""
+ if session_id.startswith('external_'):
+ self.external_sessions[session_id] = session_data
+ else:
+ # Legacy support for phone_ prefixed sessions
+ self.phone_sessions[session_id] = session_data
+
+ def _remove_session(self, session_id: str):
+ """Remove session from both locations"""
+ self.external_sessions.pop(session_id, None)
+ self.phone_sessions.pop(session_id, None)
+
+ async def connect(self, websocket: WebSocket) -> str:
+ """Accept a new WebSocket connection and return connection ID"""
+ await websocket.accept()
+ connection_id = str(uuid.uuid4())
+
+ self.connections[connection_id] = websocket
+ self.connection_metadata[connection_id] = {
+ "connected_at": asyncio.get_event_loop().time(),
+ "camera_sources": [],
+ "peer_id": None
+ }
+
+ logger.info(f"๐ WebRTC client connected: {connection_id}")
+ logger.info(f"๐ Total connections: {len(self.connections)}")
+
+ return connection_id
+
+ def disconnect(self, connection_id: str):
+ """Handle client disconnection"""
+ if connection_id in self.connections:
+ # Clean up camera sources for this connection
+ metadata = self.connection_metadata.get(connection_id, {})
+ camera_sources = metadata.get("camera_sources", [])
+
+ for source_id in camera_sources:
+ if source_id in self.camera_sources:
+ logger.info(f"๐๏ธ Removing camera source: {source_id}")
+ del self.camera_sources[source_id]
+
+ # Remove connection
+ del self.connections[connection_id]
+ if connection_id in self.connection_metadata:
+ del self.connection_metadata[connection_id]
+
+ logger.info(f"๐ WebRTC client disconnected: {connection_id}")
+ logger.info(f"๐ Total connections: {len(self.connections)}")
+
+ async def handle_message(self, connection_id: str, message: Dict[str, Any]):
+ """Handle incoming signaling message"""
+ try:
+ message_type = message.get("type")
+ source_id = message.get("sourceId")
+
+ logger.info(f"๐จ Received {message_type} for source {source_id} from {connection_id}")
+
+ if message_type == "register-camera":
+ await self._handle_register_camera(connection_id, message)
+ elif message_type == "offer":
+ await self._handle_offer(connection_id, message)
+ elif message_type == "answer":
+ await self._handle_answer(connection_id, message)
+ elif message_type == "ice-candidate":
+ await self._handle_ice_candidate(connection_id, message)
+ elif message_type == "request-camera-list":
+ await self._handle_camera_list_request(connection_id)
+ elif message_type == "request-camera-stream":
+ await self._handle_camera_stream_request(connection_id, message)
+ elif message_type == "create-session":
+ await self._handle_create_session(connection_id, message)
+ elif message_type == "join-session":
+ await self._handle_join_session(connection_id, message)
+ else:
+ logger.warning(f"โ ๏ธ Unknown message type: {message_type}")
+
+ except Exception as e:
+ logger.error(f"โ Error handling message from {connection_id}: {e}")
+ await self._send_error(connection_id, str(e))
+
+ async def _handle_register_camera(self, connection_id: str, message: Dict[str, Any]):
+ """Register a new camera source"""
+ source_id = message.get("sourceId")
+ payload = message.get("payload", {})
+
+ camera_info = {
+ "id": source_id,
+ "name": payload.get("name", "Unknown Camera"),
+ "type": payload.get("type", "local"),
+ "width": payload.get("width", 640),
+ "height": payload.get("height", 480),
+ "fps": payload.get("fps", 30),
+ "owner_connection": connection_id,
+ "status": "available",
+ "registered_at": asyncio.get_event_loop().time()
+ }
+
+ self.camera_sources[source_id] = camera_info
+
+ # Add to connection metadata
+ if connection_id in self.connection_metadata:
+ self.connection_metadata[connection_id]["camera_sources"].append(source_id)
+
+ logger.info(f"๐น Registered camera: {camera_info['name']} ({source_id})")
+
+ # Broadcast camera list update to all connections
+ await self._broadcast_camera_list()
+
+ # Confirm registration
+ await self._send_message(connection_id, {
+ "type": "camera-registered",
+ "sourceId": source_id,
+ "payload": {"status": "success"},
+ "timestamp": int(asyncio.get_event_loop().time() * 1000)
+ })
+
+ async def _handle_offer(self, connection_id: str, message: Dict[str, Any]):
+ """Handle WebRTC offer"""
+ source_id = message.get("sourceId")
+ target_id = message.get("targetId")
+
+ logger.info(f"๐ค Processing offer from {connection_id} for session {source_id}")
+
+ # Check if this is an external camera session offer
+ session = self._get_session(source_id)
+ if session:
+ desktop_client = session.get("desktop_client")
+
+ logger.info(f"๐ค External camera offer for session {source_id}, forwarding to desktop {desktop_client}")
+ logger.info(f"๐ค Session status: {session.get('status')}")
+ logger.info(f"๐ค External device client: {session.get('external_client') or session.get('phone_client')}")
+
+ if desktop_client and desktop_client in self.connections:
+ # Forward offer to desktop client
+ await self._send_message(desktop_client, message)
+ else:
+ logger.error(f"โ Desktop client {desktop_client} not found for session {source_id}")
+ await self._send_error(connection_id, f"Desktop client not connected for session {source_id}")
+ elif target_id and target_id in self.connections:
+ # Forward offer to target connection (for remote cameras)
+ await self._send_message(target_id, message)
+ else:
+ # For local cameras, this might be an offer to the server
+ # We can handle it here or forward to all other connections
+ logger.info(f"๐ค Broadcasting offer for camera {source_id}")
+ await self._broadcast_message(message, exclude=connection_id)
+
+ async def _handle_answer(self, connection_id: str, message: Dict[str, Any]):
+ """Handle WebRTC answer"""
+ source_id = message.get("sourceId")
+ target_id = message.get("targetId")
+
+ logger.info(f"๐ฅ Processing answer from {connection_id} for session {source_id}")
+
+ # Check if this is an external camera session answer
+ session = self._get_session(source_id)
+ if session:
+ external_client = session.get("external_client") or session.get("phone_client") # Legacy support
+
+ logger.info(f"๐ฅ Desktop answer for session {source_id}, forwarding to external device {external_client}")
+
+ if external_client and external_client in self.connections:
+ # Forward answer to external device client
+ await self._send_message(external_client, message)
+ else:
+ logger.error(f"โ External device client {external_client} not found for session {source_id}")
+ await self._send_error(connection_id, f"External device client not connected for session {source_id}")
+ elif target_id and target_id in self.connections:
+ # Forward answer to target connection
+ await self._send_message(target_id, message)
+ else:
+ # Broadcast answer
+ await self._broadcast_message(message, exclude=connection_id)
+
+ async def _handle_ice_candidate(self, connection_id: str, message: Dict[str, Any]):
+ """Handle ICE candidate"""
+ source_id = message.get("sourceId")
+ target_id = message.get("targetId")
+ candidate_info = message.get("payload", {}).get("candidate", {})
+
+ logger.info(f"๐ง Processing ICE candidate from {connection_id} for session {source_id}")
+ logger.info(f"๐ง Candidate type: {candidate_info.get('type', 'unknown')}")
+
+ # Check if this is an external camera session ICE candidate
+ session = self._get_session(source_id)
+ if session:
+ external_client = session.get("external_client") or session.get("phone_client") # Legacy support
+
+ # Determine if this is from external device or desktop
+ if connection_id == external_client:
+ # From external device to desktop
+ desktop_client = session.get("desktop_client")
+ logger.info(f"๐ง External device ICE candidate for session {source_id}, forwarding to desktop {desktop_client}")
+
+ if desktop_client and desktop_client in self.connections:
+ await self._send_message(desktop_client, message)
+ else:
+ logger.error(f"โ Desktop client {desktop_client} not found for ICE candidate")
+
+ elif connection_id == session.get("desktop_client"):
+ # From desktop to external device
+ logger.info(f"๐ง Desktop ICE candidate for session {source_id}, forwarding to external device {external_client}")
+
+ if external_client and external_client in self.connections:
+ await self._send_message(external_client, message)
+ else:
+ logger.error(f"โ External device client {external_client} not found for ICE candidate")
+ else:
+ logger.warning(f"โ ๏ธ ICE candidate from unknown connection {connection_id} for session {source_id}")
+
+ elif target_id and target_id in self.connections:
+ # Forward ICE candidate to target connection
+ await self._send_message(target_id, message)
+ else:
+ # Broadcast ICE candidate
+ await self._broadcast_message(message, exclude=connection_id)
+
+ async def _handle_camera_list_request(self, connection_id: str):
+ """Send available camera list to requesting connection"""
+ camera_list = []
+ for source_id, camera_info in self.camera_sources.items():
+ camera_list.append({
+ "id": source_id,
+ "name": camera_info["name"],
+ "type": camera_info["type"],
+ "available": camera_info["status"] == "available",
+ "width": camera_info["width"],
+ "height": camera_info["height"],
+ "fps": camera_info["fps"]
+ })
+
+ await self._send_message(connection_id, {
+ "type": "camera-list",
+ "sourceId": "server",
+ "payload": {"cameras": camera_list},
+ "timestamp": int(asyncio.get_event_loop().time() * 1000)
+ })
+
+ async def _handle_camera_stream_request(self, connection_id: str, message: Dict[str, Any]):
+ """Handle request for camera stream"""
+ requested_source_id = message.get("payload", {}).get("sourceId")
+
+ if requested_source_id in self.camera_sources:
+ camera_info = self.camera_sources[requested_source_id]
+ owner_connection = camera_info["owner_connection"]
+
+ if owner_connection in self.connections:
+ # Forward stream request to camera owner
+ stream_request = {
+ "type": "stream-request",
+ "sourceId": requested_source_id,
+ "targetId": connection_id,
+ "payload": {"requesterId": connection_id},
+ "timestamp": int(asyncio.get_event_loop().time() * 1000)
+ }
+ await self._send_message(owner_connection, stream_request)
+ else:
+ await self._send_error(connection_id, f"Camera owner not connected")
+ else:
+ await self._send_error(connection_id, f"Camera {requested_source_id} not found")
+
+ async def _handle_create_session(self, connection_id: str, message: Dict[str, Any]):
+ """Handle external camera session creation"""
+ session_id = message.get("sourceId")
+ payload = message.get("payload", {})
+
+ if not session_id:
+ await self._send_error(connection_id, "Session ID is required")
+ return
+
+ # Check if session already exists
+ existing_session = self._get_session(session_id)
+ session_existed = existing_session is not None
+
+ if existing_session:
+ logger.info(f"๐ Session {session_id} already exists, updating desktop client and re-sending QR URL")
+ # Update the desktop client (in case it's a reconnection)
+ existing_session["desktop_client"] = connection_id
+ existing_session["name"] = payload.get("name", existing_session.get("name", "External Camera"))
+ self._set_session(session_id, existing_session)
+
+ # Log session status for debugging
+ external_client = existing_session.get("external_client") or existing_session.get("phone_client")
+ logger.info(f"๐ Existing session status - external_client: {external_client}, status: {existing_session.get('status')}")
+ else:
+ # Create new external camera session
+ session_info = {
+ "session_id": session_id,
+ "name": payload.get("name", "External Camera"),
+ "desktop_client": connection_id,
+ "external_client": None,
+ "phone_client": None, # Legacy support
+ "status": "waiting_for_device",
+ "created_at": asyncio.get_event_loop().time()
+ }
+
+ self._set_session(session_id, session_info)
+ logger.info(f"๐ Created new external camera session: {session_id}")
+
+ # Generate QR URL - only use external URL (ngrok), no local fallback
+ from .config import get_external_url
+ external_url = get_external_url()
+
+ if external_url:
+ # Use external URL (ngrok, tunneling, etc.)
+ qr_url = f"{external_url}/remote_cam/{session_id}"
+ logger.info(f"๐ฑ Using external URL for QR: {external_url}")
+
+ # Send confirmation to desktop with QR URL and session info
+ session_info = existing_session if existing_session else self._get_session(session_id)
+ external_client = session_info.get("external_client") or session_info.get("phone_client") if session_info else None
+ device_connected = external_client is not None and external_client in self.connections
+
+ await self._send_message(connection_id, {
+ "type": "session-created",
+ "sourceId": session_id,
+ "payload": {
+ "sessionId": session_id,
+ "qrCodeUrl": qr_url,
+ "sessionExisted": session_existed,
+ "deviceConnected": device_connected,
+ "sessionStatus": session_info.get("status") if session_info else "new"
+ },
+ "timestamp": int(asyncio.get_event_loop().time() * 1000)
+ })
+ else:
+ # No external URL configured - don't generate local URLs due to HTTPS issues
+ logger.warning(f"๐ No external URL configured - external camera requires ngrok setup")
+
+ # Send confirmation to desktop without QR URL (will show ngrok required message)
+ session_info = existing_session if existing_session else self._get_session(session_id)
+ external_client = session_info.get("external_client") or session_info.get("phone_client") if session_info else None
+ device_connected = external_client is not None and external_client in self.connections
+
+ await self._send_message(connection_id, {
+ "type": "session-created",
+ "sourceId": session_id,
+ "payload": {
+ "sessionId": session_id,
+ "sessionExisted": session_existed,
+ "deviceConnected": device_connected,
+ "sessionStatus": session_info.get("status") if session_info else "new"
+ # No qrCodeUrl - frontend will show "configure ngrok" message
+ },
+ "timestamp": int(asyncio.get_event_loop().time() * 1000)
+ })
+
+ async def _handle_join_session(self, connection_id: str, message: Dict[str, Any]):
+ """Handle external device joining a session"""
+ session_id = message.get("sourceId")
+ payload = message.get("payload", {})
+
+ session = self._get_session(session_id)
+ if not session_id or not session:
+ await self._send_error(connection_id, "Session not found")
+ return
+
+ # Enhanced debugging for multiple sessions
+ logger.info(f"๐ External device joining session: {session_id}")
+ logger.info(f"๐ Device details: {payload}")
+ logger.info(f"๐ Active sessions: {len(self.external_sessions) + len(self.phone_sessions)}")
+ logger.info(f"๐ Total connections: {len(self.connections)}")
+
+ # Check for existing external device connection (Android multi-session issue)
+ existing_client = session.get("external_client") or session.get("phone_client") # Legacy support
+ if existing_client and existing_client in self.connections:
+ logger.warning(f"โ ๏ธ Session {session_id} already has a device connected: {existing_client}")
+ logger.warning(f"โ ๏ธ New device connection: {connection_id}")
+ logger.warning(f"โ ๏ธ This might be an Android multi-session conflict")
+
+ session["external_client"] = connection_id
+ session["phone_client"] = connection_id # Legacy support
+ session["status"] = "device_connected"
+ session["device_info"] = {
+ "userAgent": payload.get("userAgent", "unknown"),
+ "platform": payload.get("platform", "unknown"),
+ "webrtcSupported": payload.get("webrtcSupported", False),
+ "connected_at": asyncio.get_event_loop().time()
+ }
+
+ logger.info(f"๐ External device joined session: {session_id} from {payload.get('platform', 'unknown')}")
+
+ # Notify desktop that external device connected
+ desktop_client = session.get("desktop_client")
+ if desktop_client and desktop_client in self.connections:
+ await self._send_message(desktop_client, {
+ "type": "external-device-connected",
+ "sourceId": session_id,
+ "payload": {
+ "sessionId": session_id,
+ "deviceInfo": session["device_info"]
+ },
+ "timestamp": int(asyncio.get_event_loop().time() * 1000)
+ })
+
+ # Confirm to external device
+ await self._send_message(connection_id, {
+ "type": "session-joined",
+ "sourceId": session_id,
+ "payload": {
+ "sessionId": session_id,
+ "activeSessions": len(self.external_sessions) + len(self.phone_sessions),
+ "totalConnections": len(self.connections)
+ },
+ "timestamp": int(asyncio.get_event_loop().time() * 1000)
+ })
+
+ async def _broadcast_camera_list(self):
+ """Broadcast updated camera list to all connections"""
+ camera_list = []
+ for source_id, camera_info in self.camera_sources.items():
+ camera_list.append({
+ "id": source_id,
+ "name": camera_info["name"],
+ "type": camera_info["type"],
+ "available": camera_info["status"] == "available"
+ })
+
+ broadcast_message = {
+ "type": "camera-list-update",
+ "sourceId": "server",
+ "payload": {"cameras": camera_list},
+ "timestamp": int(asyncio.get_event_loop().time() * 1000)
+ }
+
+ await self._broadcast_message(broadcast_message)
+
+ async def _send_message(self, connection_id: str, message: Dict[str, Any]):
+ """Send message to specific connection"""
+ if connection_id in self.connections:
+ try:
+ websocket = self.connections[connection_id]
+ await websocket.send_text(json.dumps(message))
+ except Exception as e:
+ logger.error(f"โ Failed to send message to {connection_id}: {e}")
+ # Remove dead connection
+ self.disconnect(connection_id)
+
+ async def _broadcast_message(self, message: Dict[str, Any], exclude: Optional[str] = None):
+ """Broadcast message to all connections except excluded one"""
+ dead_connections = []
+
+ for connection_id, websocket in self.connections.items():
+ if connection_id == exclude:
+ continue
+
+ try:
+ await websocket.send_text(json.dumps(message))
+ except Exception as e:
+ logger.error(f"โ Failed to broadcast to {connection_id}: {e}")
+ dead_connections.append(connection_id)
+
+ # Clean up dead connections
+ for dead_id in dead_connections:
+ self.disconnect(dead_id)
+
+ async def _send_error(self, connection_id: str, error_message: str):
+ """Send error message to connection"""
+ error_msg = {
+ "type": "error",
+ "sourceId": "server",
+ "payload": {"error": error_message},
+ "timestamp": int(asyncio.get_event_loop().time() * 1000)
+ }
+ await self._send_message(connection_id, error_msg)
+
+ def get_stats(self) -> Dict[str, Any]:
+ """Get signaling server statistics"""
+ return {
+ "total_connections": len(self.connections),
+ "total_cameras": len(self.camera_sources),
+ "local_cameras": len([c for c in self.camera_sources.values() if c["type"] == "local"]),
+ "remote_cameras": len([c for c in self.camera_sources.values() if c["type"] == "remote"]),
+ "active_cameras": len([c for c in self.camera_sources.values() if c["status"] == "available"])
+ }
+
+# Global signaling server instance
+signaling_server = WebRTCSignalingServer()
\ No newline at end of file
diff --git a/scripts/backend.py b/scripts/backend.py
index 325ae95..fc9c6f7 100644
--- a/scripts/backend.py
+++ b/scripts/backend.py
@@ -5,18 +5,80 @@
import logging
import uvicorn
+import ssl
+import os
# Set up logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
+def create_ssl_context(cert_file: str, key_file: str):
+ """Create an SSL context optimized for iOS compatibility"""
+ try:
+ # Create SSL context with iOS-compatible settings
+ ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
+ ssl_context.load_cert_chain(cert_file, key_file)
+
+ # Set minimum and maximum TLS versions for iOS compatibility
+ ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
+ ssl_context.maximum_version = ssl.TLSVersion.TLSv1_3
+
+ # Use iOS-compatible cipher suites
+ ssl_context.set_ciphers('ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:!aNULL:!MD5:!DSS')
+
+ # Additional iOS-friendly settings
+ ssl_context.check_hostname = False # For self-signed certificates
+ ssl_context.verify_mode = ssl.CERT_NONE # For self-signed certificates
+
+ logger.info("โ
SSL context created with iOS-compatible settings")
+ return ssl_context
+
+ except Exception as e:
+ logger.error(f"โ Failed to create SSL context: {e}")
+ return None
+
+
def main():
"""Start the FastAPI backend server only"""
logger.info("๐ Starting LeLab FastAPI backend server...")
- uvicorn.run(
- "app.main:app", host="0.0.0.0", port=8000, reload=True, log_level="info"
- )
+
+ # Check if SSL certificates exist
+ cert_file = "cert.pem"
+ key_file = "key.pem"
+
+ if os.path.exists(cert_file) and os.path.exists(key_file):
+ logger.info("๐ SSL certificates found, starting HTTPS server...")
+
+ # Try to create iOS-compatible SSL context
+ ssl_context = create_ssl_context(cert_file, key_file)
+
+ if ssl_context:
+ logger.info("๐ Using iOS-compatible SSL configuration")
+ uvicorn.run(
+ "app.main:app",
+ host="0.0.0.0",
+ port=8000,
+ reload=True,
+ log_level="info",
+ ssl_context=ssl_context
+ )
+ else:
+ logger.warning("โ ๏ธ Falling back to basic SSL configuration")
+ uvicorn.run(
+ "app.main:app",
+ host="0.0.0.0",
+ port=8000,
+ reload=True,
+ log_level="info",
+ ssl_keyfile=key_file,
+ ssl_certfile=cert_file
+ )
+ else:
+ logger.info("๐ No SSL certificates found, starting HTTP server...")
+ uvicorn.run(
+ "app.main:app", host="0.0.0.0", port=8000, reload=True, log_level="info"
+ )
if __name__ == "__main__":