Spaces:
Running
Running
| 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 { findPort, isWebSerialSupported } from "@lerobot/web"; | |
| import type { RobotConnection } from "@lerobot/web"; | |
| interface PortManagerProps { | |
| connectedRobots: RobotConnection[]; | |
| onConnectedRobotsChange: (robots: RobotConnection[]) => void; | |
| onCalibrate?: (port: any) => void; // Let library handle port type | |
| onTeleoperate?: (robot: RobotConnection) => void; | |
| } | |
| export function PortManager({ | |
| connectedRobots, | |
| onConnectedRobotsChange, | |
| onCalibrate, | |
| onTeleoperate, | |
| }: PortManagerProps) { | |
| const [isFindingPorts, setIsFindingPorts] = useState(false); | |
| const [findPortsLog, setFindPortsLog] = useState<string[]>([]); | |
| const [error, setError] = useState<string | null>(null); | |
| // Load saved robots on mount by calling findPort with saved data | |
| useEffect(() => { | |
| loadSavedRobots(); | |
| }, []); | |
| const loadSavedRobots = async () => { | |
| try { | |
| console.log("๐ Loading saved robots from localStorage..."); | |
| // Load saved robot configs for auto-connect mode | |
| const robotConfigs: any[] = []; | |
| const { getUnifiedRobotData } = await import("../lib/unified-storage"); | |
| // Check localStorage for saved robot data | |
| for (let i = 0; i < localStorage.length; i++) { | |
| const key = localStorage.key(i); | |
| if (key && key.startsWith("lerobotjs-")) { | |
| const serialNumber = key.replace("lerobotjs-", ""); | |
| const robotData = getUnifiedRobotData(serialNumber); | |
| if (robotData) { | |
| console.log( | |
| `โ Found saved robot: ${robotData.device_info.robotId}` | |
| ); | |
| // Create robot config for auto-connect mode | |
| robotConfigs.push({ | |
| robotType: robotData.device_info.robotType, | |
| robotId: robotData.device_info.robotId, | |
| serialNumber: serialNumber, | |
| }); | |
| } | |
| } | |
| } | |
| if (robotConfigs.length > 0) { | |
| console.log( | |
| `๐ Auto-connecting to ${robotConfigs.length} saved robots...` | |
| ); | |
| // Use auto-connect mode - NO DIALOG will be shown! | |
| const findPortProcess = await findPort({ | |
| robotConfigs, | |
| onMessage: (message) => { | |
| console.log(`Auto-connect: ${message}`); | |
| }, | |
| }); | |
| const reconnectedRobots = await findPortProcess.result; | |
| console.log( | |
| `โ Auto-connected to ${ | |
| reconnectedRobots.filter((r) => r.isConnected).length | |
| }/${robotConfigs.length} saved robots` | |
| ); | |
| onConnectedRobotsChange(reconnectedRobots); | |
| } else { | |
| console.log("No saved robots found in localStorage"); | |
| } | |
| } catch (error) { | |
| console.error("Failed to load saved robots:", error); | |
| } | |
| }; | |
| const handleFindPorts = async () => { | |
| if (!isWebSerialSupported()) { | |
| setError("Web Serial API is not supported in this browser"); | |
| return; | |
| } | |
| try { | |
| setIsFindingPorts(true); | |
| setFindPortsLog([]); | |
| setError(null); | |
| // Use clean library API - library handles everything! | |
| const findPortProcess = await findPort({ | |
| onMessage: (message) => { | |
| setFindPortsLog((prev) => [...prev, message]); | |
| }, | |
| }); | |
| const robotConnections = await findPortProcess.result; | |
| // Add new robots to the list (avoid duplicates) | |
| const newRobots = robotConnections.filter( | |
| (newRobot) => | |
| !connectedRobots.some( | |
| (existing) => existing.serialNumber === newRobot.serialNumber | |
| ) | |
| ); | |
| onConnectedRobotsChange([...connectedRobots, ...newRobots]); | |
| setFindPortsLog((prev) => [ | |
| ...prev, | |
| `โ Found ${newRobots.length} new robots`, | |
| ]); | |
| } catch (error) { | |
| if ( | |
| error instanceof Error && | |
| (error.message.includes("cancelled") || | |
| error.name === "NotAllowedError") | |
| ) { | |
| console.log("Port discovery cancelled by user"); | |
| return; | |
| } | |
| setError(error instanceof Error ? error.message : "Failed to find ports"); | |
| } finally { | |
| setIsFindingPorts(false); | |
| } | |
| }; | |
| const handleDisconnect = (index: number) => { | |
| const updatedRobots = connectedRobots.filter((_, i) => i !== index); | |
| onConnectedRobotsChange(updatedRobots); | |
| }; | |
| const handleCalibrate = (robot: RobotConnection) => { | |
| if (!robot.robotType || !robot.robotId) { | |
| setError("Please configure robot type and ID first"); | |
| return; | |
| } | |
| if (onCalibrate) { | |
| onCalibrate(robot.port); | |
| } | |
| }; | |
| const handleTeleoperate = (robot: RobotConnection) => { | |
| if (!robot.robotType || !robot.robotId) { | |
| setError("Please configure robot type and ID first"); | |
| return; | |
| } | |
| if (!robot.isConnected || !robot.port) { | |
| setError( | |
| "Robot is not connected. Please use 'Find & Connect Robots' first." | |
| ); | |
| return; | |
| } | |
| // Robot is connected, proceed with teleoperation | |
| if (onTeleoperate) { | |
| onTeleoperate(robot); | |
| } | |
| }; | |
| return ( | |
| <Card> | |
| <CardHeader> | |
| <CardTitle>๐ Robot Connection Manager</CardTitle> | |
| <CardDescription> | |
| Find and connect to your robot devices | |
| </CardDescription> | |
| </CardHeader> | |
| <CardContent> | |
| <div className="space-y-6"> | |
| {/* Error Display */} | |
| {error && ( | |
| <Alert variant="destructive"> | |
| <AlertDescription>{error}</AlertDescription> | |
| </Alert> | |
| )} | |
| {/* Find Ports Button */} | |
| <Button | |
| onClick={handleFindPorts} | |
| disabled={isFindingPorts || !isWebSerialSupported()} | |
| className="w-full" | |
| > | |
| {isFindingPorts ? "Finding Robots..." : "๐ Find & Connect Robots"} | |
| </Button> | |
| {/* Find Ports Log */} | |
| {findPortsLog.length > 0 && ( | |
| <div className="bg-gray-50 p-3 rounded-md text-sm space-y-1 max-h-32 overflow-y-auto"> | |
| {findPortsLog.map((log, index) => ( | |
| <div key={index} className="text-gray-700"> | |
| {log} | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {/* Connected Robots */} | |
| <div> | |
| <h4 className="font-semibold mb-3"> | |
| Connected Robots ({connectedRobots.length}) | |
| </h4> | |
| {connectedRobots.length === 0 ? ( | |
| <div className="text-center py-8 text-gray-500"> | |
| <div className="text-2xl mb-2">๐ค</div> | |
| <p>No robots found</p> | |
| <p className="text-xs"> | |
| Click "Find & Connect Robots" to discover devices | |
| </p> | |
| </div> | |
| ) : ( | |
| <div className="space-y-4"> | |
| {connectedRobots.map((robot, index) => ( | |
| <RobotCard | |
| key={robot.serialNumber || index} | |
| robot={robot} | |
| onDisconnect={() => handleDisconnect(index)} | |
| onCalibrate={() => handleCalibrate(robot)} | |
| onTeleoperate={() => handleTeleoperate(robot)} | |
| /> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| ); | |
| } | |
| interface RobotCardProps { | |
| robot: RobotConnection; | |
| onDisconnect: () => void; | |
| onCalibrate: () => void; | |
| onTeleoperate: () => void; | |
| } | |
| function RobotCard({ | |
| robot, | |
| onDisconnect, | |
| onCalibrate, | |
| onTeleoperate, | |
| }: RobotCardProps) { | |
| const [calibrationStatus, setCalibrationStatus] = useState<{ | |
| timestamp: string; | |
| readCount: number; | |
| } | null>(null); | |
| const [isEditing, setIsEditing] = useState(false); | |
| const [editRobotType, setEditRobotType] = useState< | |
| "so100_follower" | "so100_leader" | |
| >(robot.robotType || "so100_follower"); | |
| const [editRobotId, setEditRobotId] = useState(robot.robotId || ""); | |
| const isConfigured = robot.robotType && robot.robotId; | |
| // Check calibration status using unified storage | |
| useEffect(() => { | |
| const checkCalibrationStatus = async () => { | |
| if (!robot.serialNumber) return; | |
| try { | |
| const { getCalibrationStatus } = await import("../lib/unified-storage"); | |
| const status = getCalibrationStatus(robot.serialNumber); | |
| setCalibrationStatus(status); | |
| } catch (error) { | |
| console.warn("Failed to check calibration status:", error); | |
| } | |
| }; | |
| checkCalibrationStatus(); | |
| }, [robot.serialNumber]); | |
| const handleSaveConfig = async () => { | |
| if (!editRobotId.trim() || !robot.serialNumber) return; | |
| try { | |
| const { saveRobotConfig } = await import("../lib/unified-storage"); | |
| saveRobotConfig( | |
| robot.serialNumber, | |
| editRobotType, | |
| editRobotId.trim(), | |
| robot.usbMetadata | |
| ); | |
| // Update the robot object (this should trigger a re-render) | |
| robot.robotType = editRobotType; | |
| robot.robotId = editRobotId.trim(); | |
| setIsEditing(false); | |
| console.log("โ Robot configuration saved"); | |
| } catch (error) { | |
| console.error("Failed to save robot configuration:", error); | |
| } | |
| }; | |
| const handleCancelEdit = () => { | |
| setEditRobotType(robot.robotType || "so100_follower"); | |
| setEditRobotId(robot.robotId || ""); | |
| setIsEditing(false); | |
| }; | |
| return ( | |
| <div className="border rounded-lg p-4 space-y-3"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center space-x-2"> | |
| <div className="flex flex-col"> | |
| <span className="font-medium"> | |
| {robot.robotId || robot.name || "Unnamed Robot"} | |
| </span> | |
| <span className="text-xs text-gray-500"> | |
| {robot.robotType?.replace("_", " ") || "Not configured"} | |
| </span> | |
| {robot.serialNumber && ( | |
| <span className="text-xs text-gray-400 font-mono"> | |
| {robot.serialNumber.length > 20 | |
| ? robot.serialNumber.substring(0, 20) + "..." | |
| : robot.serialNumber} | |
| </span> | |
| )} | |
| </div> | |
| <div className="flex flex-col gap-1"> | |
| <Badge variant={robot.isConnected ? "default" : "outline"}> | |
| {robot.isConnected ? "Connected" : "Available"} | |
| </Badge> | |
| {calibrationStatus && ( | |
| <Badge variant="default" className="bg-green-100 text-green-800"> | |
| โ Calibrated | |
| </Badge> | |
| )} | |
| </div> | |
| </div> | |
| <Button variant="destructive" size="sm" onClick={onDisconnect}> | |
| Remove | |
| </Button> | |
| </div> | |
| {/* Robot Configuration Display (when not editing) */} | |
| {!isEditing && isConfigured && ( | |
| <div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"> | |
| <div className="flex items-center space-x-3"> | |
| <div> | |
| <div className="font-medium text-sm">{robot.robotId}</div> | |
| <div className="text-xs text-gray-600"> | |
| {robot.robotType?.replace("_", " ")} | |
| </div> | |
| </div> | |
| </div> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => setIsEditing(true)} | |
| > | |
| Edit | |
| </Button> | |
| </div> | |
| )} | |
| {/* Configuration Prompt for unconfigured robots */} | |
| {!isEditing && !isConfigured && ( | |
| <div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg"> | |
| <div className="text-sm text-blue-800"> | |
| Robot needs configuration before use | |
| </div> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => setIsEditing(true)} | |
| > | |
| Configure | |
| </Button> | |
| </div> | |
| )} | |
| {/* Robot Configuration Form (when editing) */} | |
| {isEditing && ( | |
| <div className="space-y-3 p-3 bg-gray-50 rounded-lg"> | |
| <div className="grid grid-cols-2 gap-3"> | |
| <div> | |
| <label className="text-sm font-medium block mb-1"> | |
| Robot Type | |
| </label> | |
| <select | |
| value={editRobotType} | |
| onChange={(e) => | |
| setEditRobotType( | |
| e.target.value as "so100_follower" | "so100_leader" | |
| ) | |
| } | |
| className="w-full px-2 py-1 border rounded text-sm" | |
| > | |
| <option value="so100_follower">SO-100 Follower</option> | |
| <option value="so100_leader">SO-100 Leader</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label className="text-sm font-medium block mb-1">Robot ID</label> | |
| <input | |
| type="text" | |
| value={editRobotId} | |
| onChange={(e) => setEditRobotId(e.target.value)} | |
| placeholder="e.g., my_robot" | |
| className="w-full px-2 py-1 border rounded text-sm" | |
| /> | |
| </div> | |
| </div> | |
| <div className="flex gap-2"> | |
| <Button | |
| size="sm" | |
| onClick={handleSaveConfig} | |
| disabled={!editRobotId.trim()} | |
| > | |
| Save | |
| </Button> | |
| <Button size="sm" variant="outline" onClick={handleCancelEdit}> | |
| Cancel | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Calibration Status */} | |
| {isConfigured && !isEditing && ( | |
| <div className="text-sm text-gray-600"> | |
| {calibrationStatus ? ( | |
| <span> | |
| Last calibrated:{" "} | |
| {new Date(calibrationStatus.timestamp).toLocaleDateString()} | |
| <span className="text-xs ml-1"> | |
| ({calibrationStatus.readCount} readings) | |
| </span> | |
| </span> | |
| ) : ( | |
| <span>Not calibrated yet</span> | |
| )} | |
| </div> | |
| )} | |
| {/* Actions */} | |
| {isConfigured && !isEditing && ( | |
| <div className="flex gap-2"> | |
| <Button | |
| size="sm" | |
| variant={calibrationStatus ? "outline" : "default"} | |
| onClick={onCalibrate} | |
| > | |
| {calibrationStatus ? "๐ Re-calibrate" : "๐ Calibrate"} | |
| </Button> | |
| <Button | |
| size="sm" | |
| variant="outline" | |
| onClick={onTeleoperate} | |
| disabled={!robot.isConnected} | |
| title={ | |
| !robot.isConnected | |
| ? "Use 'Find & Connect Robots' first" | |
| : undefined | |
| } | |
| > | |
| ๐ฎ Teleoperate | |
| </Button> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |