diff --git a/.gitignore b/.gitignore index 8b514e8..7e70145 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,19 @@ package-lock.json *.db *.sqlite *.sqlite3 + +# Generated certificates and SSL files +certs/ +*.pem +*.key +*.crt +*.cert + +# Test files and temporary scripts +test_*.py +test_*.html +test-*.html + +# Generated documentation +HTTPS_SETUP.md +PHONE_CAMERA_SETUP.md diff --git a/app/main.py b/app/main.py index 668bb97..b816194 100644 --- a/app/main.py +++ b/app/main.py @@ -2,6 +2,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse from fastapi.middleware.cors import CORSMiddleware +import socketio import os import logging import glob @@ -9,8 +10,10 @@ from typing import List, Dict, Any import threading import queue +import time from pathlib import Path from . import config +from .webrtc_signaling import get_socketio_app # Import our custom recording functionality from .recording import ( @@ -76,6 +79,12 @@ allow_headers=["*"], # Allows all headers ) +# Get Socket.IO app for WebRTC signaling +sio = get_socketio_app() + +# Create Socket.IO ASGI app +socket_app = socketio.ASGIApp(sio, app) + # Create static directory if it doesn't exist os.makedirs("app/static", exist_ok=True) @@ -665,6 +674,143 @@ def get_robot_config(robot_type: str, available_configs: str = ""): return {"status": "error", "message": str(e)} +# ============================================================================ +# WEBRTC STREAM MANAGEMENT ENDPOINTS +# ============================================================================ + +@app.get("/webrtc/streams") +def get_active_streams(): + """Get list of all active WebRTC streams""" + try: + from .webrtc_signaling import active_streams, stream_buffers + + streams = [] + for stream_id, stream_data in active_streams.items(): + stream_copy = stream_data.copy() + stream_copy['buffer_size'] = len(stream_buffers.get(stream_id, [])) + streams.append(stream_copy) + + return {"success": True, "streams": streams} + except Exception as e: + logger.error(f"Error getting active streams: {str(e)}") + return {"success": False, "error": str(e)} + +@app.get("/webrtc/streams/{stream_id}") +def get_stream_info(stream_id: str): + """Get detailed information about a specific stream""" + try: + from .webrtc_signaling import active_streams, stream_buffers + + if stream_id not in active_streams: + return {"success": False, "error": "Stream not found"} + + stream_info = active_streams[stream_id].copy() + stream_info['buffer_size'] = len(stream_buffers.get(stream_id, [])) + + # Add buffer statistics + if stream_id in stream_buffers and stream_buffers[stream_id]: + buffer = stream_buffers[stream_id] + stream_info['buffer_stats'] = { + 'oldest_frame': buffer[0]['timestamp'] if buffer else None, + 'newest_frame': buffer[-1]['timestamp'] if buffer else None, + 'total_frames': len(buffer) + } + + return {"success": True, "stream": stream_info} + except Exception as e: + logger.error(f"Error getting stream info for {stream_id}: {str(e)}") + return {"success": False, "error": str(e)} + +@app.get("/webrtc/sessions") +def get_active_sessions(): + """Get list of all active WebRTC sessions""" + try: + from .webrtc_signaling import active_sessions + + sessions = [] + for webrtc_id, session_data in active_sessions.items(): + session_copy = session_data.copy() + # Remove sensitive client IDs from public endpoint + session_copy.pop('desktop_client', None) + session_copy.pop('phone_client', None) + sessions.append(session_copy) + + return {"success": True, "sessions": sessions} + except Exception as e: + logger.error(f"Error getting active sessions: {str(e)}") + return {"success": False, "error": str(e)} + +@app.post("/webrtc/test-stream") +def create_test_stream(): + """Create a test stream for development/testing purposes""" + try: + import uuid + from datetime import datetime + from .webrtc_signaling import active_streams, stream_buffers + + # Generate test stream + stream_id = str(uuid.uuid4()) + test_webrtc_id = f"test_{int(time.time())}" + + # Create test stream entry + active_streams[stream_id] = { + 'stream_id': stream_id, + 'webrtc_id': test_webrtc_id, + 'created_at': datetime.now().isoformat(), + 'status': 'test_active', + 'metadata': { + 'width': 640, + 'height': 480, + 'fps': 30, + 'codec': 'h264', + 'test': True + } + } + + # Initialize with test frame data + stream_buffers[stream_id] = [ + { + 'timestamp': time.time(), + 'data': 'test_frame_data_placeholder', + 'sequence': 0 + } + ] + + logger.info(f"Created test stream: {stream_id}") + + return { + "success": True, + "stream_id": stream_id, + "webrtc_id": test_webrtc_id, + "message": "Test stream created successfully" + } + + except Exception as e: + logger.error(f"Error creating test stream: {str(e)}") + return {"success": False, "error": str(e)} + +@app.delete("/webrtc/streams/{stream_id}") +def delete_stream(stream_id: str): + """Delete a specific stream and its buffer""" + try: + from .webrtc_signaling import active_streams, stream_buffers + + if stream_id not in active_streams: + return {"success": False, "error": "Stream not found"} + + # Remove stream and buffer + del active_streams[stream_id] + if stream_id in stream_buffers: + del stream_buffers[stream_id] + + logger.info(f"Deleted stream: {stream_id}") + return {"success": True, "message": "Stream deleted successfully"} + + except Exception as e: + logger.error(f"Error deleting stream {stream_id}: {str(e)}") + return {"success": False, "error": str(e)} + + @app.on_event("shutdown") async def shutdown_event(): """Clean up resources when FastAPI shuts down""" @@ -680,3 +826,7 @@ async def shutdown_event(): if manager: manager.stop_broadcast_thread() logger.info("āœ… Cleanup completed") + +# Create a combined app that serves both FastAPI and Socket.IO +# The socket_app already wraps the FastAPI app, so we use it as the main app +main_app = socket_app diff --git a/app/webrtc_signaling.py b/app/webrtc_signaling.py new file mode 100644 index 0000000..054db88 --- /dev/null +++ b/app/webrtc_signaling.py @@ -0,0 +1,345 @@ +import socketio +import logging +from typing import Dict, Set, Optional, Any +import time # Use regular time instead of asyncio for timestamps +import uuid +import json +import asyncio +from datetime import datetime + +# Set up logging +logger = logging.getLogger(__name__) + +# Socket.IO server for WebRTC signaling +sio = socketio.AsyncServer( + cors_allowed_origins="*", + logger=True, + engineio_logger=True +) + +# Store active WebRTC sessions +active_sessions: Dict[str, Dict] = {} +session_clients: Dict[str, Set[str]] = {} # webrtc_id -> set of client session IDs + +# Store active video streams +active_streams: Dict[str, Dict] = {} # stream_id -> stream metadata +stream_buffers: Dict[str, list] = {} # stream_id -> buffered video frames + +# Stream configuration +MAX_BUFFER_SIZE = 100 # Maximum frames to buffer per stream +STREAM_TIMEOUT = 300 # Stream timeout in seconds + +@sio.event +async def connect(sid, environ): + """Handle client connection""" + logger.info(f"Client connected: {sid}") + +@sio.event +async def disconnect(sid): + """Handle client disconnection""" + logger.info(f"Client disconnected: {sid}") + + # Clean up any sessions this client was part of + sessions_to_clean = [] + streams_to_clean = [] + + for webrtc_id, clients in session_clients.items(): + if sid in clients: + clients.remove(sid) + if len(clients) == 0: + sessions_to_clean.append(webrtc_id) + + # Mark associated stream for cleanup + session = active_sessions.get(webrtc_id) + if session and session.get('stream_id'): + streams_to_clean.append(session['stream_id']) + + # Remove empty sessions and associated streams + for webrtc_id in sessions_to_clean: + if webrtc_id in active_sessions: + del active_sessions[webrtc_id] + if webrtc_id in session_clients: + del session_clients[webrtc_id] + logger.info(f"Cleaned up empty WebRTC session: {webrtc_id}") + + # Clean up streams + for stream_id in streams_to_clean: + if stream_id in active_streams: + del active_streams[stream_id] + if stream_id in stream_buffers: + del stream_buffers[stream_id] + logger.info(f"Cleaned up stream: {stream_id}") + +@sio.event +async def create_session(sid, data): + """Create a new WebRTC session for a phone camera""" + webrtc_id = data.get('webrtcId') + if not webrtc_id: + await sio.emit('session-error', {'error': 'No webrtcId provided'}, room=sid) + return + + logger.info(f"Creating WebRTC session: {webrtc_id} for client: {sid}") + + # Generate unique stream ID for this session + stream_id = str(uuid.uuid4()) + + # Initialize session + active_sessions[webrtc_id] = { + 'created_at': time.time(), + 'status': 'waiting_for_phone', + 'desktop_client': sid, + 'phone_client': None, + 'stream_id': stream_id, + 'session_id': str(uuid.uuid4()) # Unique session identifier + } + + # Initialize stream storage + active_streams[stream_id] = { + 'stream_id': stream_id, + 'webrtc_id': webrtc_id, + 'created_at': datetime.now().isoformat(), + 'status': 'waiting_for_stream', + 'metadata': { + 'width': None, + 'height': None, + 'fps': None, + 'codec': None + } + } + + stream_buffers[stream_id] = [] + + if webrtc_id not in session_clients: + session_clients[webrtc_id] = set() + session_clients[webrtc_id].add(sid) + + await sio.emit('session-created', { + 'webrtcId': webrtc_id, + 'streamId': stream_id, + 'sessionId': active_sessions[webrtc_id]['session_id'] + }, room=sid) + +@sio.event +async def join_session(sid, data): + """Phone joins an existing WebRTC session""" + webrtc_id = data.get('webrtcId') + if not webrtc_id: + await sio.emit('session-error', {'error': 'No webrtcId provided'}, room=sid) + return + + logger.info(f"Phone joining WebRTC session: {webrtc_id} from client: {sid}") + + if webrtc_id not in active_sessions: + await sio.emit('session-error', {'error': 'Session not found'}, room=sid) + return + + # Update session with phone client + session = active_sessions[webrtc_id] + session['phone_client'] = sid + session['status'] = 'phone_connected' + + if webrtc_id not in session_clients: + session_clients[webrtc_id] = set() + session_clients[webrtc_id].add(sid) + + # Notify desktop client that phone has connected + desktop_client = session.get('desktop_client') + if desktop_client: + await sio.emit('phone-connected', {'webrtcId': webrtc_id}, room=desktop_client) + + await sio.emit('session-joined', {'webrtcId': webrtc_id}, room=sid) + +@sio.event +async def signal(sid, data): + """Forward WebRTC signaling data between desktop and phone""" + webrtc_id = data.get('webrtcId') + signal_data = data.get('signal') + + if not webrtc_id or not signal_data: + await sio.emit('session-error', {'error': 'Invalid signaling data'}, room=sid) + return + + logger.info(f"Forwarding signal for session: {webrtc_id} from client: {sid}") + + if webrtc_id not in active_sessions: + await sio.emit('session-error', {'error': 'Session not found'}, room=sid) + return + + session = active_sessions[webrtc_id] + + # Forward signal to the other client in the session + if sid == session.get('desktop_client'): + # Signal from desktop to phone + phone_client = session.get('phone_client') + if phone_client: + await sio.emit('signal', { + 'webrtcId': webrtc_id, + 'signal': signal_data + }, room=phone_client) + elif sid == session.get('phone_client'): + # Signal from phone to desktop + desktop_client = session.get('desktop_client') + if desktop_client: + await sio.emit('signal', { + 'webrtcId': webrtc_id, + 'signal': signal_data + }, room=desktop_client) + else: + await sio.emit('session-error', {'error': 'Client not part of session'}, room=sid) + +@sio.event +async def stream_ready(sid, data): + """Handle notification that stream is ready to send data""" + webrtc_id = data.get('webrtcId') + stream_metadata = data.get('metadata', {}) + + if not webrtc_id: + await sio.emit('stream-error', {'error': 'No webrtcId provided'}, room=sid) + return + + session = active_sessions.get(webrtc_id) + if not session: + await sio.emit('stream-error', {'error': 'Session not found'}, room=sid) + return + + stream_id = session.get('stream_id') + if stream_id and stream_id in active_streams: + # Update stream metadata + active_streams[stream_id]['status'] = 'active' + active_streams[stream_id]['metadata'].update(stream_metadata) + + logger.info(f"Stream {stream_id} is now active for session {webrtc_id}") + + # Notify other participants + await sio.emit('stream-started', { + 'webrtcId': webrtc_id, + 'streamId': stream_id, + 'metadata': active_streams[stream_id]['metadata'] + }, room=session.get('desktop_client')) + +@sio.event +async def stream_data(sid, data): + """Handle incoming video stream data""" + webrtc_id = data.get('webrtcId') + frame_data = data.get('frameData') + timestamp = data.get('timestamp', time.time()) + + if not webrtc_id or not frame_data: + return + + session = active_sessions.get(webrtc_id) + if not session: + return + + stream_id = session.get('stream_id') + if not stream_id or stream_id not in stream_buffers: + return + + # Add frame to buffer + frame_entry = { + 'timestamp': timestamp, + 'data': frame_data, + 'sequence': len(stream_buffers[stream_id]) + } + + # Manage buffer size + if len(stream_buffers[stream_id]) >= MAX_BUFFER_SIZE: + stream_buffers[stream_id].pop(0) # Remove oldest frame + + stream_buffers[stream_id].append(frame_entry) + + # Update stream status + if stream_id in active_streams: + active_streams[stream_id]['last_frame'] = timestamp + + # Forward to frontend clients if needed + desktop_client = session.get('desktop_client') + if desktop_client: + await sio.emit('stream-frame', { + 'webrtcId': webrtc_id, + 'streamId': stream_id, + 'frameData': frame_data, + 'timestamp': timestamp + }, room=desktop_client) + +@sio.event +async def get_stream_info(sid, data): + """Get information about a specific stream""" + stream_id = data.get('streamId') + + if not stream_id: + await sio.emit('stream-error', {'error': 'No streamId provided'}, room=sid) + return + + if stream_id not in active_streams: + await sio.emit('stream-error', {'error': 'Stream not found'}, room=sid) + return + + stream_info = active_streams[stream_id].copy() + stream_info['buffer_size'] = len(stream_buffers.get(stream_id, [])) + + await sio.emit('stream-info', stream_info, room=sid) + +@sio.event +async def list_active_streams(sid, data): + """List all active streams""" + streams = [] + for stream_id, stream_data in active_streams.items(): + stream_copy = stream_data.copy() + stream_copy['buffer_size'] = len(stream_buffers.get(stream_id, [])) + streams.append(stream_copy) + + await sio.emit('active-streams', {'streams': streams}, room=sid) + +@sio.event +async def destroy_session(sid, data): + """Destroy a WebRTC session""" + webrtc_id = data.get('webrtcId') + if not webrtc_id: + return + + logger.info(f"Destroying WebRTC session: {webrtc_id} by client: {sid}") + + # Clean up session + if webrtc_id in active_sessions: + session = active_sessions[webrtc_id] + + # Notify other client in session + for client_type in ['desktop_client', 'phone_client']: + client_sid = session.get(client_type) + if client_sid and client_sid != sid: + await sio.emit('session-destroyed', {'webrtcId': webrtc_id}, room=client_sid) + + del active_sessions[webrtc_id] + + if webrtc_id in session_clients: + del session_clients[webrtc_id] + +async def cleanup_expired_sessions(): + """Clean up sessions that have been inactive for too long""" + current_time = time.time() # Use regular time + expired_sessions = [] + + for webrtc_id, session in active_sessions.items(): + # Expire sessions after 1 hour + if current_time - session['created_at'] > 3600: + expired_sessions.append(webrtc_id) + + for webrtc_id in expired_sessions: + logger.info(f"Cleaning up expired session: {webrtc_id}") + if webrtc_id in active_sessions: + del active_sessions[webrtc_id] + if webrtc_id in session_clients: + del session_clients[webrtc_id] + +# Periodic cleanup task +async def start_cleanup_task(): + """Start periodic cleanup of expired sessions""" + import asyncio + while True: + await asyncio.sleep(300) # Check every 5 minutes + await cleanup_expired_sessions() + +def get_socketio_app(): + """Get the Socket.IO app for integration with FastAPI""" + return sio diff --git a/generate_certs.py b/generate_certs.py new file mode 100644 index 0000000..214e31c --- /dev/null +++ b/generate_certs.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +""" +Generate self-signed certificates for local HTTPS development. +Required for phone camera access via WebRTC. +""" + +import os +import socket +import subprocess +import sys +from pathlib import Path +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +import datetime +import ipaddress + + +def get_local_ip(): + """Get the local network IP address""" + try: + # Connect to a remote address (doesn't actually establish connection) + 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: + return "127.0.0.1" + + +def check_mkcert(): + """Check if mkcert is available""" + try: + result = subprocess.run(['mkcert', '-version'], + capture_output=True, text=True, timeout=10) + return result.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + + +def generate_with_mkcert(cert_dir, local_ip): + """Generate certificates using mkcert (trusted by browsers)""" + print("šŸ” Using mkcert to generate trusted certificates...") + + cert_path = cert_dir / "cert.pem" + key_path = cert_dir / "key.pem" + + try: + # Install local CA if not already done + subprocess.run(['mkcert', '-install'], check=True, timeout=30) + + # Generate certificate for localhost and local IP + cmd = [ + 'mkcert', + '-cert-file', str(cert_path), + '-key-file', str(key_path), + 'localhost', + '127.0.0.1', + local_ip + ] + + result = subprocess.run(cmd, check=True, timeout=30, + capture_output=True, text=True) + + print(f"āœ… Certificates generated successfully:") + print(f" Certificate: {cert_path}") + print(f" Private Key: {key_path}") + print(f" Valid for: localhost, 127.0.0.1, {local_ip}") + + return True + + except subprocess.CalledProcessError as e: + print(f"āŒ mkcert failed: {e}") + print(f" stdout: {e.stdout}") + print(f" stderr: {e.stderr}") + return False + except subprocess.TimeoutExpired: + print("āŒ mkcert timed out") + return False + + +def generate_self_signed(cert_dir, local_ip): + """Generate self-signed certificates using Python cryptography library""" + print("šŸ” Generating self-signed certificates...") + + cert_path = cert_dir / "cert.pem" + key_path = cert_dir / "key.pem" + + try: + # Generate private key + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + + # Create certificate + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, "US"), + x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "Development"), + x509.NameAttribute(NameOID.LOCALITY_NAME, "Local"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "LeRobot Development"), + x509.NameAttribute(NameOID.COMMON_NAME, "localhost"), + ]) + + # Build subject alternative names + san_list = [ + x509.DNSName("localhost"), + x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")), + ] + + # Add local IP if it's different from localhost + if local_ip != "127.0.0.1": + try: + san_list.append(x509.IPAddress(ipaddress.IPv4Address(local_ip))) + except ipaddress.AddressValueError: + print(f"āš ļø Invalid IP address: {local_ip}") + + cert = x509.CertificateBuilder().subject_name( + subject + ).issuer_name( + issuer + ).public_key( + private_key.public_key() + ).serial_number( + x509.random_serial_number() + ).not_valid_before( + datetime.datetime.utcnow() + ).not_valid_after( + datetime.datetime.utcnow() + datetime.timedelta(days=365) + ).add_extension( + x509.SubjectAlternativeName(san_list), + critical=False, + ).sign(private_key, hashes.SHA256()) + + # Write private key + with open(key_path, "wb") as f: + f.write(private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + )) + + # Write certificate + with open(cert_path, "wb") as f: + f.write(cert.public_bytes(serialization.Encoding.PEM)) + + print(f"āœ… Self-signed certificates generated:") + print(f" Certificate: {cert_path}") + print(f" Private Key: {key_path}") + print(f" Valid for: localhost, 127.0.0.1, {local_ip}") + print("āš ļø Note: Self-signed certificates will show security warnings in browsers") + print(" Consider installing mkcert for trusted certificates: https://github.com/FiloSottile/mkcert") + + return True + + except Exception as e: + print(f"āŒ Failed to generate self-signed certificate: {e}") + return False + + +def main(): + """Main function to generate certificates""" + print("šŸš€ Setting up HTTPS certificates for LeRobot phone camera support...") + + # Create certs directory + cert_dir = Path("certs") + cert_dir.mkdir(exist_ok=True) + + # Get local IP + local_ip = get_local_ip() + print(f"šŸ“” Detected local IP: {local_ip}") + + # Check if certificates already exist + cert_path = cert_dir / "cert.pem" + key_path = cert_dir / "key.pem" + + if cert_path.exists() and key_path.exists(): + response = input("šŸ“‹ Certificates already exist. Regenerate? (y/N): ").strip().lower() + if response not in ['y', 'yes']: + print("āœ… Using existing certificates") + return + + # Try mkcert first, fallback to self-signed + success = False + + if check_mkcert(): + success = generate_with_mkcert(cert_dir, local_ip) + else: + print("šŸ“¦ mkcert not found. Install it for trusted certificates:") + print(" https://github.com/FiloSottile/mkcert") + print(" Falling back to self-signed certificates...") + + if not success: + success = generate_self_signed(cert_dir, local_ip) + + if success: + print("\nšŸŽ‰ HTTPS setup complete!") + print(f"šŸ’” You can now access the frontend via: https://{local_ip}:5173") + print("šŸ“± Phones on the same Wi-Fi can scan QR codes to connect.") + + # Create HTTPS setup guide + with open("HTTPS_SETUP.md", "w") as f: + f.write(f"""# HTTPS Setup for Phone Camera Support + +## Generated Files +- `certs/cert.pem` - SSL certificate +- `certs/key.pem` - Private key + +## Usage +The frontend should automatically use HTTPS when these certificates are present. + +## Access URLs +- Desktop: https://localhost:5173 +- Mobile: https://{local_ip}:5173 + +## Security Notes +- These certificates are for development only +- Self-signed certificates will show browser warnings +- For trusted certificates, install mkcert: https://github.com/FiloSottile/mkcert + +## Troubleshooting +If you see certificate errors: +1. Accept the security warning in your browser +2. For mobile devices, you may need to manually accept the certificate +3. Consider using mkcert for automatically trusted certificates + +Generated on: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')} +""") + print("šŸ“ Created HTTPS_SETUP.md with detailed instructions") + + else: + print("āŒ Failed to generate certificates") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 8b9d7c2..90baba2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "fastapi[standard]>=0.115.12", "websockets>=15.0.1", "uvicorn>=0.24.0", + "python-socketio>=5.11.0", "lerobot @ git+https://github.com/huggingface/lerobot.git", ] @@ -25,4 +26,4 @@ where = ["."] include = ["app*", "scripts*"] [tool.setuptools.package-data] -app = ["static/**/*"] \ No newline at end of file +app = ["static/**/*"] diff --git a/scripts/start_phone_camera_demo.py b/scripts/start_phone_camera_demo.py new file mode 100755 index 0000000..0120b33 --- /dev/null +++ b/scripts/start_phone_camera_demo.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +Startup script for LeRobot with phone camera support. +This script starts both frontend and backend with HTTPS enabled. +""" + +import os +import sys +import subprocess +import time +import socket +from pathlib import Path + +def get_local_ip(): + """Get the local network IP address""" + try: + # Connect to a remote address (doesn't actually establish connection) + 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: + return "localhost" + +def check_certificates(): + """Check if SSL certificates exist""" + cert_path = Path("certs/cert.pem") + key_path = Path("certs/key.pem") + + if not cert_path.exists() or not key_path.exists(): + print("āŒ SSL certificates not found!") + print("šŸ“‹ Run 'python generate_certs.py' first to set up HTTPS") + return False + + print("āœ… SSL certificates found") + return True + +def start_backend(): + """Start the FastAPI backend with HTTPS""" + print("šŸš€ Starting backend with HTTPS...") + + # Start backend with SSL certificates + backend_cmd = [ + "uvicorn", + "app.main:app", + "--host", "0.0.0.0", + "--port", "8000", + "--ssl-keyfile", "certs/key.pem", + "--ssl-certfile", "certs/cert.pem", + "--reload" + ] + + try: + backend_process = subprocess.Popen( + backend_cmd, + cwd=Path("."), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + universal_newlines=True + ) + + print("āœ… Backend starting...") + return backend_process + except Exception as e: + print(f"āŒ Failed to start backend: {e}") + return None + +def start_frontend(): + """Start the Vite frontend with HTTPS""" + print("šŸŽØ Starting frontend with HTTPS...") + + frontend_dir = Path("leLab-space") + if not frontend_dir.exists(): + print("āŒ Frontend directory not found!") + return None + + try: + # Install dependencies if needed + if not (frontend_dir / "node_modules").exists(): + print("šŸ“¦ Installing frontend dependencies...") + subprocess.run(["npm", "install"], cwd=frontend_dir, check=True) + + # Start Vite dev server (will use HTTPS automatically if certificates exist) + frontend_process = subprocess.Popen( + ["npm", "run", "dev"], + cwd=frontend_dir, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + universal_newlines=True + ) + + print("āœ… Frontend starting...") + return frontend_process + except Exception as e: + print(f"āŒ Failed to start frontend: {e}") + return None + +def print_access_info(): + """Print access information""" + local_ip = get_local_ip() + + print("\nšŸŽ‰ LeRobot with Phone Camera Support is running!") + print("=" * 50) + print(f"šŸ–„ļø Desktop Access:") + print(f" https://localhost:5173") + print(f" https://{local_ip}:5173") + print(f"") + print(f"šŸ“± Mobile Access:") + print(f" https://{local_ip}:5173") + print(f" (Scan QR codes in camera configuration)") + print(f"") + print(f"šŸ”§ API Backend:") + print(f" https://localhost:8000") + print(f" https://{local_ip}:8000") + print("=" * 50) + print("šŸ’” To add a phone camera:") + print("1. Go to Recording page") + print("2. Select 'Phone Camera' from dropdown") + print("3. Give it a name and click 'Add Camera'") + print("4. Scan the QR code with your phone") + print("5. Start streaming from your phone") + print("") + print("āš ļø Note: You may need to accept SSL certificate warnings") + print("šŸ“‹ For trusted certificates, install mkcert and rerun generate_certs.py") + print("") + print("šŸ›‘ Press Ctrl+C to stop all services") + +def main(): + """Main function""" + print("šŸš€ Starting LeRobot with Phone Camera Support...") + + # Check if we're in the right directory + if not Path("app").exists(): + print("āŒ Please run this script from the LeRobot root directory") + sys.exit(1) + + # Check certificates + if not check_certificates(): + print("\nšŸ”§ Generating certificates automatically...") + try: + subprocess.run([sys.executable, "generate_certs.py"], check=True) + print("āœ… Certificates generated successfully!") + except subprocess.CalledProcessError: + print("āŒ Failed to generate certificates") + sys.exit(1) + + # Start services + backend_process = start_backend() + if not backend_process: + sys.exit(1) + + # Wait a moment for backend to start + time.sleep(3) + + frontend_process = start_frontend() + if not frontend_process: + backend_process.terminate() + sys.exit(1) + + # Wait for frontend to be ready + time.sleep(5) + + # Print access information + print_access_info() + + try: + # Keep running and monitor processes + while True: + time.sleep(1) + + # Check if processes are still running + if backend_process.poll() is not None: + print("āŒ Backend process died") + break + + if frontend_process.poll() is not None: + print("āŒ Frontend process died") + break + + except KeyboardInterrupt: + print("\nšŸ›‘ Shutting down...") + + finally: + # Clean up processes + if backend_process and backend_process.poll() is None: + backend_process.terminate() + backend_process.wait() + + if frontend_process and frontend_process.poll() is None: + frontend_process.terminate() + frontend_process.wait() + + print("āœ… Shutdown complete") + +if __name__ == "__main__": + main() diff --git a/scripts/start_webrtc_backend.py b/scripts/start_webrtc_backend.py new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/scripts/start_webrtc_backend.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/webrtc_backend.py b/scripts/webrtc_backend.py new file mode 100644 index 0000000..09a1194 --- /dev/null +++ b/scripts/webrtc_backend.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +""" +Start the backend server with WebRTC support enabled. +This script starts both FastAPI and Socket.IO servers for WebRTC signaling. +""" + +import sys +import os +import logging +import argparse + +# Add the parent directory to the path so we can import the app +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +def main(): + parser = argparse.ArgumentParser(description="Start LeRobot Lab Backend with WebRTC") + parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)") + parser.add_argument("--port", type=int, default=8000, help="Port to bind to (default: 8000)") + parser.add_argument("--https", action="store_true", help="Enable HTTPS with SSL certificates") + parser.add_argument("--cert-file", default="certs/cert.pem", help="SSL certificate file path") + parser.add_argument("--key-file", default="certs/key.pem", help="SSL private key file path") + parser.add_argument("--log-level", default="info", choices=["debug", "info", "warning", "error"], help="Log level") + parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development") + + args = parser.parse_args() + + # Set up logging + logging.basicConfig( + level=getattr(logging, args.log_level.upper()), + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + + logger = logging.getLogger(__name__) + logger.info("šŸš€ Starting LeRobot Lab Backend with WebRTC support...") + logger.info(f"šŸ“” WebRTC signaling enabled") + + # Determine protocol based on HTTPS setting + protocol = "https" if args.https else "http" + logger.info(f"🌐 Server will be available at {protocol}://{args.host}:{args.port}") + logger.info(f"šŸ”Œ Socket.IO endpoint: {protocol}://{args.host}:{args.port}/socket.io/") + + if args.https: + if not os.path.exists(args.cert_file) or not os.path.exists(args.key_file): + logger.error(f"āŒ SSL certificates not found!") + logger.error(f" Certificate: {args.cert_file}") + logger.error(f" Private key: {args.key_file}") + logger.error("šŸ’” Generate certificates with: python generate_certs.py") + sys.exit(1) + logger.info(f"šŸ” HTTPS enabled with certificates:") + logger.info(f" Certificate: {args.cert_file}") + logger.info(f" Private key: {args.key_file}") + + try: + import uvicorn + from app.main import main_app + + # Run the combined FastAPI + Socket.IO app + uvicorn_config = { + "host": args.host, + "port": args.port, + "log_level": args.log_level, + "reload": args.reload + } + + # Add SSL configuration if HTTPS is enabled + if args.https: + uvicorn_config.update({ + "ssl_certfile": args.cert_file, + "ssl_keyfile": args.key_file + }) + + uvicorn.run(main_app, **uvicorn_config) + + except ImportError as e: + logger.error(f"āŒ Missing dependency: {e}") + logger.error("šŸ“¦ Please install required packages:") + logger.error(" pip install 'fastapi[standard]' uvicorn python-socketio") + sys.exit(1) + except Exception as e: + logger.error(f"āŒ Failed to start server: {e}") + sys.exit(1) + +if __name__ == "__main__": + main()