import { useState, useEffect } from "react"; import { Button } from "./ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "./ui/card"; import { Alert, AlertDescription } from "./ui/alert"; import { Badge } from "./ui/badge"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "./ui/dialog"; import { isWebSerialSupported } from "@lerobot/web"; import type { RobotConnection } from "@lerobot/web"; /** * Type definitions for WebSerial API (missing from TypeScript) */ interface SerialPortInfo { usbVendorId?: number; usbProductId?: number; } declare global { interface SerialPort { getInfo(): SerialPortInfo; } } interface PortManagerProps { connectedRobots: RobotConnection[]; onConnectedRobotsChange: (robots: RobotConnection[]) => void; onCalibrate?: ( port: SerialPort, robotType: "so100_follower" | "so100_leader", robotId: string ) => void; onTeleoperate?: (robot: RobotConnection) => void; } export function PortManager({ connectedRobots, onConnectedRobotsChange, onCalibrate, onTeleoperate, }: PortManagerProps) { const [isConnecting, setIsConnecting] = useState(false); const [isFindingPorts, setIsFindingPorts] = useState(false); const [findPortsLog, setFindPortsLog] = useState([]); const [error, setError] = useState(null); const [confirmDeleteDialog, setConfirmDeleteDialog] = useState<{ open: boolean; robotIndex: number; robotName: string; serialNumber: string; }>({ open: false, robotIndex: -1, robotName: "", serialNumber: "", }); // Load saved port data from localStorage on mount useEffect(() => { loadSavedPorts(); }, []); // Note: Robot data is now automatically saved to unified storage when robot config is updated const loadSavedPorts = async () => { try { const existingPorts = await navigator.serial.getPorts(); const restoredPorts: RobotConnection[] = []; for (const port of existingPorts) { // Get USB device metadata to determine serial number let serialNumber = null; let usbMetadata = null; try { // Get all USB devices and try to match with this serial port const usbDevices = await navigator.usb.getDevices(); const portInfo = port.getInfo(); // Try to find matching USB device by vendor/product ID const matchingDevice = usbDevices.find( (device) => device.vendorId === portInfo.usbVendorId && device.productId === portInfo.usbProductId ); if (matchingDevice) { serialNumber = matchingDevice.serialNumber || `${matchingDevice.vendorId}-${ matchingDevice.productId }-${Date.now()}`; usbMetadata = { vendorId: `0x${matchingDevice.vendorId .toString(16) .padStart(4, "0")}`, productId: `0x${matchingDevice.productId .toString(16) .padStart(4, "0")}`, serialNumber: matchingDevice.serialNumber || "Generated ID", manufacturerName: matchingDevice.manufacturerName || "Unknown", productName: matchingDevice.productName || "Unknown", usbVersionMajor: matchingDevice.usbVersionMajor, usbVersionMinor: matchingDevice.usbVersionMinor, deviceClass: matchingDevice.deviceClass, deviceSubclass: matchingDevice.deviceSubclass, deviceProtocol: matchingDevice.deviceProtocol, }; console.log("✅ Restored USB metadata for port:", serialNumber); } } catch (usbError) { console.log("⚠️ Could not restore USB metadata:", usbError); // Generate fallback if no USB metadata available serialNumber = `fallback-${Date.now()}-${Math.random() .toString(36) .substr(2, 9)}`; } // Load robot configuration from unified storage let robotType: "so100_follower" | "so100_leader" | undefined; let robotId: string | undefined; let shouldAutoConnect = false; if (serialNumber) { try { const { getUnifiedRobotData } = await import( "../lib/unified-storage" ); const unifiedData = getUnifiedRobotData(serialNumber); if (unifiedData?.device_info) { robotType = unifiedData.device_info.robotType; robotId = unifiedData.device_info.robotId; shouldAutoConnect = true; console.log( `📋 Loaded robot config from unified storage: ${robotType} (${robotId})` ); } } catch (error) { console.warn("Failed to load unified robot data:", error); } } // Auto-connect to configured robots let isConnected = false; try { // Check if already open if (port.readable !== null && port.writable !== null) { isConnected = true; console.log("Port already open, reusing connection"); } else if (shouldAutoConnect && robotType && robotId) { // Auto-open robots that have saved configuration console.log( `Auto-connecting to saved robot: ${robotType} (${robotId})` ); await port.open({ baudRate: 1000000 }); isConnected = true; } else { console.log( "Port found but no saved robot configuration, skipping auto-connect" ); isConnected = false; } } catch (error) { console.log("Could not auto-connect to robot:", error); isConnected = false; } restoredPorts.push({ port, name: getPortDisplayName(port), isConnected, robotType, robotId, serialNumber: serialNumber!, usbMetadata: usbMetadata || undefined, }); } onConnectedRobotsChange(restoredPorts); } catch (error) { console.error("Failed to load saved ports:", error); } }; const getPortDisplayName = (port: SerialPort): string => { try { const info = port.getInfo(); if (info.usbVendorId && info.usbProductId) { return `USB Port (${info.usbVendorId}:${info.usbProductId})`; } if (info.usbVendorId) { return `Serial Port (VID:${info.usbVendorId .toString(16) .toUpperCase()})`; } } catch (error) { // getInfo() might not be available } return `Serial Port ${Date.now()}`; }; const handleConnect = async () => { if (!isWebSerialSupported()) { setError("Web Serial API is not supported in this browser"); return; } try { setIsConnecting(true); setError(null); // Step 1: Request Web Serial port console.log("Step 1: Requesting Web Serial port..."); const port = await navigator.serial.requestPort(); await port.open({ baudRate: 1000000 }); // Step 2: Request WebUSB device for metadata console.log( "Step 2: Requesting WebUSB device for unique identification..." ); let serialNumber = null; let usbMetadata = null; try { // Request USB device access for metadata const usbDevice = await navigator.usb.requestDevice({ filters: [ { vendorId: 0x0403 }, // FTDI { vendorId: 0x067b }, // Prolific { vendorId: 0x10c4 }, // Silicon Labs { vendorId: 0x1a86 }, // QinHeng Electronics (CH340) { vendorId: 0x239a }, // Adafruit { vendorId: 0x2341 }, // Arduino { vendorId: 0x2e8a }, // Raspberry Pi Foundation { vendorId: 0x1b4f }, // SparkFun ], }); if (usbDevice) { serialNumber = usbDevice.serialNumber || `${usbDevice.vendorId}-${usbDevice.productId}-${Date.now()}`; usbMetadata = { vendorId: `0x${usbDevice.vendorId.toString(16).padStart(4, "0")}`, productId: `0x${usbDevice.productId.toString(16).padStart(4, "0")}`, serialNumber: usbDevice.serialNumber || "Generated ID", manufacturerName: usbDevice.manufacturerName || "Unknown", productName: usbDevice.productName || "Unknown", usbVersionMajor: usbDevice.usbVersionMajor, usbVersionMinor: usbDevice.usbVersionMinor, deviceClass: usbDevice.deviceClass, deviceSubclass: usbDevice.deviceSubclass, deviceProtocol: usbDevice.deviceProtocol, }; console.log("✅ USB device metadata acquired:", usbMetadata); } } catch (usbError) { console.log( "⚠️ WebUSB request failed, generating fallback ID:", usbError ); // Generate a fallback unique ID if WebUSB fails serialNumber = `fallback-${Date.now()}-${Math.random() .toString(36) .substr(2, 9)}`; usbMetadata = { vendorId: "Unknown", productId: "Unknown", serialNumber: serialNumber, manufacturerName: "USB Metadata Not Available", productName: "Check browser WebUSB support", }; } const portName = getPortDisplayName(port); // Step 3: Check if this robot (by serial number) is already connected const existingIndex = connectedRobots.findIndex( (robot) => robot.serialNumber === serialNumber ); if (existingIndex === -1) { // New robot - add to list const newRobot: RobotConnection = { port, name: portName, isConnected: true, serialNumber: serialNumber!, usbMetadata: usbMetadata || undefined, }; // Try to load saved robot info by serial number using unified storage if (serialNumber) { try { const { getRobotConfig } = await import("../lib/unified-storage"); const savedConfig = getRobotConfig(serialNumber); if (savedConfig) { newRobot.robotType = savedConfig.robotType as | "so100_follower" | "so100_leader"; newRobot.robotId = savedConfig.robotId; console.log("📋 Loaded saved robot configuration:", savedConfig); } } catch (error) { console.warn("Failed to load saved robot data:", error); } } onConnectedRobotsChange([...connectedRobots, newRobot]); console.log("🤖 New robot connected with ID:", serialNumber); } else { // Existing robot - update port and connection status const updatedRobots = connectedRobots.map((robot, index) => index === existingIndex ? { ...robot, port, isConnected: true, name: portName } : robot ); onConnectedRobotsChange(updatedRobots); console.log("🔄 Existing robot reconnected:", serialNumber); } } catch (error) { if ( error instanceof Error && (error.message.includes("cancelled") || error.message.includes("No port selected by the user") || error.name === "NotAllowedError") ) { // User cancelled - no error message needed, just log to console console.log("Connection cancelled by user"); return; } setError( error instanceof Error ? error.message : "Failed to connect to robot" ); } finally { setIsConnecting(false); } }; const handleDisconnect = async (index: number) => { const portInfo = connectedRobots[index]; const robotName = portInfo.robotId || portInfo.name; const serialNumber = portInfo.serialNumber || "unknown"; // Show confirmation dialog setConfirmDeleteDialog({ open: true, robotIndex: index, robotName, serialNumber, }); }; const confirmDelete = async () => { const { robotIndex } = confirmDeleteDialog; const portInfo = connectedRobots[robotIndex]; setConfirmDeleteDialog({ open: false, robotIndex: -1, robotName: "", serialNumber: "", }); try { // Close the serial port connection if (portInfo.isConnected) { await portInfo.port.close(); } // Delete from unified storage if serial number is available if (portInfo.serialNumber) { try { const { getUnifiedKey } = await import("../lib/unified-storage"); const unifiedKey = getUnifiedKey(portInfo.serialNumber); // Remove unified storage data localStorage.removeItem(unifiedKey); console.log(`🗑️ Deleted unified robot data: ${unifiedKey}`); } catch (error) { console.warn("Failed to delete unified storage data:", error); } } // Remove from UI const updatedRobots = connectedRobots.filter((_, i) => i !== robotIndex); onConnectedRobotsChange(updatedRobots); console.log( `✅ Robot "${confirmDeleteDialog.robotName}" permanently removed from system` ); } catch (error) { setError( error instanceof Error ? error.message : "Failed to remove robot" ); } }; const cancelDelete = () => { setConfirmDeleteDialog({ open: false, robotIndex: -1, robotName: "", serialNumber: "", }); }; const handleUpdatePortInfo = ( index: number, robotType: "so100_follower" | "so100_leader", robotId: string ) => { const updatedRobots = connectedRobots.map((robot, i) => { if (i === index) { const updatedRobot = { ...robot, robotType, robotId }; // Save robot configuration using unified storage if (updatedRobot.serialNumber) { import("../lib/unified-storage") .then(({ saveRobotConfig }) => { saveRobotConfig( updatedRobot.serialNumber!, robotType, robotId, updatedRobot.usbMetadata ); console.log( "💾 Saved robot configuration for:", updatedRobot.serialNumber ); }) .catch((error) => { console.warn("Failed to save robot configuration:", error); }); } return updatedRobot; } return robot; }); onConnectedRobotsChange(updatedRobots); }; const handleFindPorts = async () => { if (!isWebSerialSupported()) { setError("Web Serial API is not supported in this browser"); return; } try { setIsFindingPorts(true); setFindPortsLog([]); setError(null); // Use the new findPort API from standard library const { findPort } = await import("@lerobot/web"); const findPortProcess = await findPort({ onMessage: (message) => { setFindPortsLog((prev) => [...prev, message]); }, }); const robotConnections = (await findPortProcess.result) as any; // RobotConnection[] from findPort const robotConnection = robotConnections[0]; // Get first robot from array const portName = getPortDisplayName(robotConnection.port); setFindPortsLog((prev) => [...prev, `✅ Port ready: ${portName}`]); // Add to connected ports if not already there const existingIndex = connectedRobots.findIndex( (p) => p.name === portName ); if (existingIndex === -1) { const newPort: RobotConnection = { port: robotConnection.port, name: portName, isConnected: true, robotType: robotConnection.robotType, robotId: robotConnection.robotId, serialNumber: robotConnection.serialNumber, }; onConnectedRobotsChange([...connectedRobots, newPort]); } } catch (error) { if ( error instanceof Error && (error.message.includes("cancelled") || error.name === "NotAllowedError") ) { // User cancelled - no message needed, just log to console console.log("Port identification cancelled by user"); return; } setError(error instanceof Error ? error.message : "Failed to find ports"); } finally { setIsFindingPorts(false); } }; const ensurePortIsOpen = async (robotIndex: number) => { const robot = connectedRobots[robotIndex]; if (!robot) return false; try { // If port is already open, we're good if (robot.port.readable !== null && robot.port.writable !== null) { return true; } // Try to open the port await robot.port.open({ baudRate: 1000000 }); // Update the robot's connection status const updatedRobots = connectedRobots.map((r, i) => i === robotIndex ? { ...r, isConnected: true } : r ); onConnectedRobotsChange(updatedRobots); return true; } catch (error) { console.error("Failed to open port for calibration:", error); setError(error instanceof Error ? error.message : "Failed to open port"); return false; } }; const handleCalibrate = async (port: RobotConnection) => { if (!port.robotType || !port.robotId) { setError("Please set robot type and ID before calibrating"); return; } // Find the robot index const robotIndex = connectedRobots.findIndex((r) => r.port === port.port); if (robotIndex === -1) { setError("Robot not found in connected robots list"); return; } // Ensure port is open before calibrating const isOpen = await ensurePortIsOpen(robotIndex); if (!isOpen) { return; // Error already set in ensurePortIsOpen } if (onCalibrate) { onCalibrate(port.port, port.robotType, port.robotId); } }; return ( 🔌 Robot Connection Manager Connect, identify, and manage your robot arms
{/* Error Display */} {error && ( {error} )} {/* Connection Controls */}
{/* Find Ports Log */} {findPortsLog.length > 0 && (
{findPortsLog.map((log, index) => (
{log}
))}
)} {/* Connected Ports */}

Connected Robots ({connectedRobots.length})

{connectedRobots.length === 0 ? (
🤖

No robots connected

Use "Connect Robot" or "Find Port" to add robots

) : (
{connectedRobots.map((portInfo, index) => ( handleDisconnect(index)} onUpdateInfo={(robotType, robotId) => handleUpdatePortInfo(index, robotType, robotId) } onCalibrate={() => handleCalibrate(portInfo)} onTeleoperate={() => onTeleoperate?.(portInfo)} /> ))}
)}
{/* Confirmation Dialog */} 🗑️ Permanently Delete Robot Data? This action cannot be undone. All robot data will be permanently deleted.
Robot Information:
• Name:{" "} {confirmDeleteDialog.robotName}
• Serial:{" "} {confirmDeleteDialog.serialNumber}
This will permanently delete:
• Robot configuration
• Calibration data
• All saved settings
); } interface PortCardProps { portInfo: RobotConnection; onDisconnect: () => void; onUpdateInfo: ( robotType: "so100_follower" | "so100_leader", robotId: string ) => void; onCalibrate: () => void; onTeleoperate: () => void; } function PortCard({ portInfo, onDisconnect, onUpdateInfo, onCalibrate, onTeleoperate, }: PortCardProps) { const [robotType, setRobotType] = useState<"so100_follower" | "so100_leader">( portInfo.robotType || "so100_follower" ); const [robotId, setRobotId] = useState(portInfo.robotId || ""); const [isEditing, setIsEditing] = useState(false); const [isScanning, setIsScanning] = useState(false); const [motorIDs, setMotorIDs] = useState([]); const [portMetadata, setPortMetadata] = useState(null); const [showDeviceInfo, setShowDeviceInfo] = useState(false); // Check for calibration using unified storage const getCalibrationStatus = () => { // Use the same serial number logic as calibration: prefer main serialNumber, fallback to USB metadata, then "unknown" const serialNumber = portInfo.serialNumber || portInfo.usbMetadata?.serialNumber || "unknown"; try { // Use unified storage system with automatic migration import("../lib/unified-storage") .then(({ getCalibrationStatus }) => { const status = getCalibrationStatus(serialNumber); return status; }) .catch((error) => { console.warn("Failed to load unified calibration data:", error); return null; }); // For immediate synchronous return, try to get existing unified data first const unifiedKey = `lerobotjs-${serialNumber}`; const existing = localStorage.getItem(unifiedKey); if (existing) { const data = JSON.parse(existing); if (data.calibration?.metadata) { return { timestamp: data.calibration.metadata.timestamp, readCount: data.calibration.metadata.readCount, }; } } } catch (error) { console.warn("Failed to read calibration from unified storage:", error); } return null; }; const calibrationStatus = getCalibrationStatus(); const handleSave = () => { if (robotId.trim()) { onUpdateInfo(robotType, robotId.trim()); setIsEditing(false); } }; // Use current values (either from props or local state) const currentRobotType = portInfo.robotType || robotType; const currentRobotId = portInfo.robotId || robotId; const handleCancel = () => { setRobotType(portInfo.robotType || "so100_follower"); setRobotId(portInfo.robotId || ""); setIsEditing(false); }; // Scan for motor IDs and gather USB device metadata const scanDeviceInfo = async () => { if (!portInfo.port || !portInfo.isConnected) { console.warn("Port not connected"); return; } setIsScanning(true); setMotorIDs([]); setPortMetadata(null); const foundIDs: number[] = []; try { // Try to get USB device info using WebUSB for better metadata let usbDeviceInfo = null; try { // First, check if we already have USB device permissions let usbDevices = await navigator.usb.getDevices(); console.log("Already permitted USB devices:", usbDevices); // If no devices found, request permission for USB-to-serial devices if (usbDevices.length === 0) { console.log( "No USB permissions yet, requesting access to USB-to-serial devices..." ); // Request access to common USB-to-serial chips try { const device = await navigator.usb.requestDevice({ filters: [ { vendorId: 0x0403 }, // FTDI { vendorId: 0x067b }, // Prolific { vendorId: 0x10c4 }, // Silicon Labs { vendorId: 0x1a86 }, // QinHeng Electronics (CH340) { vendorId: 0x239a }, // Adafruit { vendorId: 0x2341 }, // Arduino { vendorId: 0x2e8a }, // Raspberry Pi Foundation { vendorId: 0x1b4f }, // SparkFun ], }); if (device) { usbDevices = [device]; console.log("USB device access granted:", device); } } catch (requestError) { console.log( "User cancelled USB device selection or no devices found" ); // Try requesting any device as fallback try { const anyDevice = await navigator.usb.requestDevice({ filters: [], // Allow any USB device }); if (anyDevice) { usbDevices = [anyDevice]; console.log("Fallback USB device selected:", anyDevice); } } catch (fallbackError) { console.log("No USB device selected"); } } } // Try to match with Web Serial port (this is tricky, so we'll take the first available) if (usbDevices.length > 0) { // Look for common USB-to-serial chip vendor IDs const serialChipVendors = [ 0x0403, // FTDI 0x067b, // Prolific 0x10c4, // Silicon Labs 0x1a86, // QinHeng Electronics (CH340) 0x239a, // Adafruit 0x2341, // Arduino 0x2e8a, // Raspberry Pi Foundation 0x1b4f, // SparkFun ]; const serialDevice = usbDevices.find((device) => serialChipVendors.includes(device.vendorId) ) || usbDevices[0]; // Fallback to first device if (serialDevice) { usbDeviceInfo = { vendorId: `0x${serialDevice.vendorId .toString(16) .padStart(4, "0")}`, productId: `0x${serialDevice.productId .toString(16) .padStart(4, "0")}`, serialNumber: serialDevice.serialNumber || "Not available", manufacturerName: serialDevice.manufacturerName || "Unknown", productName: serialDevice.productName || "Unknown", usbVersionMajor: serialDevice.usbVersionMajor, usbVersionMinor: serialDevice.usbVersionMinor, deviceClass: serialDevice.deviceClass, deviceSubclass: serialDevice.deviceSubclass, deviceProtocol: serialDevice.deviceProtocol, }; console.log("USB device info:", usbDeviceInfo); } } } catch (usbError) { console.log("WebUSB not available or no permissions:", usbError); // Fallback to Web Serial API info const portInfo_metadata = portInfo.port.getInfo(); console.log("Serial port metadata fallback:", portInfo_metadata); if (Object.keys(portInfo_metadata).length > 0) { usbDeviceInfo = { vendorId: portInfo_metadata.usbVendorId ? `0x${portInfo_metadata.usbVendorId .toString(16) .padStart(4, "0")}` : "Not available", productId: portInfo_metadata.usbProductId ? `0x${portInfo_metadata.usbProductId .toString(16) .padStart(4, "0")}` : "Not available", serialNumber: "Not available via Web Serial", manufacturerName: "Not available via Web Serial", productName: "Not available via Web Serial", }; } } setPortMetadata(usbDeviceInfo); // Get reader/writer for the port const reader = portInfo.port.readable?.getReader(); const writer = portInfo.port.writable?.getWriter(); if (!reader || !writer) { console.warn("Cannot access port reader/writer"); setShowDeviceInfo(true); return; } // Test motor IDs 1-10 (common range for servos) for (let motorId = 1; motorId <= 10; motorId++) { try { // Create STS3215 ping packet const packet = new Uint8Array([ 0xff, 0xff, motorId, 0x02, 0x01, 0x00, ]); const checksum = ~(motorId + 0x02 + 0x01) & 0xff; packet[5] = checksum; // Send ping await writer.write(packet); // Wait a bit for response await new Promise((resolve) => setTimeout(resolve, 20)); // Try to read response with timeout const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), 50) ); try { const result = (await Promise.race([ reader.read(), timeoutPromise, ])) as ReadableStreamReadResult; if ( result && !result.done && result.value && result.value.length >= 6 ) { const response = result.value; const responseId = response[2]; // If we got a response with matching ID, motor exists if (responseId === motorId) { foundIDs.push(motorId); } } } catch (readError) { // No response from this motor ID - that's normal } } catch (error) { console.warn(`Error testing motor ID ${motorId}:`, error); } // Small delay between tests await new Promise((resolve) => setTimeout(resolve, 10)); } reader.releaseLock(); writer.releaseLock(); setMotorIDs(foundIDs); setShowDeviceInfo(true); } catch (error) { console.error("Device info scan failed:", error); } finally { setIsScanning(false); } }; return (
{/* Header with port name and status */}
{portInfo.name} {portInfo.serialNumber && ( ID:{" "} {portInfo.serialNumber.length > 20 ? portInfo.serialNumber.substring(0, 20) + "..." : portInfo.serialNumber} )}
{portInfo.isConnected ? "Connected" : "Available"}
{/* Robot Info Display (when not editing) */} {!isEditing && currentRobotType && currentRobotId && (
{currentRobotId}
{currentRobotType.replace("_", " ")}
{calibrationStatus && ( ✅ Calibrated )}
)} {/* Setup prompt for unconfigured robots */} {!isEditing && (!currentRobotType || !currentRobotId) && (
Robot needs configuration before use
)} {/* Robot Configuration Form (when editing) */} {isEditing && (
setRobotId(e.target.value)} placeholder="e.g., my_robot" className="w-full px-2 py-1 border rounded text-sm" />
)} {/* Calibration Status and Action */} {currentRobotType && currentRobotId && (
{calibrationStatus ? ( Last calibrated:{" "} {new Date(calibrationStatus.timestamp).toLocaleDateString()} ({calibrationStatus.readCount} readings) ) : ( Not calibrated yet )}
{/* Device Info Scanner */}
Scan device info and motor IDs
{/* Device Info Results */} {showDeviceInfo && (
{/* USB Device Information */} {portMetadata && (
📱 USB Device Info:
Vendor ID: {portMetadata.vendorId}
Product ID: {portMetadata.productId}
Serial Number: {portMetadata.serialNumber}
Manufacturer: {portMetadata.manufacturerName}
Product: {portMetadata.productName}
{portMetadata.usbVersionMajor && (
USB Version: {portMetadata.usbVersionMajor}. {portMetadata.usbVersionMinor}
)} {portMetadata.deviceClass !== undefined && (
Device Class: 0x {portMetadata.deviceClass .toString(16) .padStart(2, "0")}
)}
)} {/* Motor IDs */}
🤖 Found Motor IDs:
{motorIDs.length > 0 ? (
{motorIDs.map((id) => ( Motor {id} ))}
) : (
No motor IDs found. Check connection and power.
)}
)}
)}
); }