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 { | |
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<string[]>([]); | |
const [error, setError] = useState<string | null>(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 ( | |
<Card> | |
<CardHeader> | |
<CardTitle>🔌 Robot Connection Manager</CardTitle> | |
<CardDescription> | |
Connect, identify, and manage your robot arms | |
</CardDescription> | |
</CardHeader> | |
<CardContent> | |
<div className="space-y-6"> | |
{/* Error Display */} | |
{error && ( | |
<Alert variant="destructive"> | |
<AlertDescription>{error}</AlertDescription> | |
</Alert> | |
)} | |
{/* Connection Controls */} | |
<div className="flex gap-2"> | |
<Button | |
onClick={handleConnect} | |
disabled={isConnecting || !isWebSerialSupported()} | |
className="flex-1" | |
> | |
{isConnecting ? "Connecting..." : "Connect Robot"} | |
</Button> | |
<Button | |
variant="outline" | |
onClick={handleFindPorts} | |
disabled={isFindingPorts || !isWebSerialSupported()} | |
className="flex-1" | |
> | |
{isFindingPorts ? "Finding..." : "Find Port"} | |
</Button> | |
</div> | |
{/* Find Ports Log */} | |
{findPortsLog.length > 0 && ( | |
<div className="bg-gray-50 p-3 rounded-md text-sm space-y-1"> | |
{findPortsLog.map((log, index) => ( | |
<div key={index} className="text-gray-700"> | |
{log} | |
</div> | |
))} | |
</div> | |
)} | |
{/* Connected Ports */} | |
<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 connected</p> | |
<p className="text-xs"> | |
Use "Connect Robot" or "Find Port" to add robots | |
</p> | |
</div> | |
) : ( | |
<div className="space-y-4"> | |
{connectedRobots.map((portInfo, index) => ( | |
<PortCard | |
key={index} | |
portInfo={portInfo} | |
onDisconnect={() => handleDisconnect(index)} | |
onUpdateInfo={(robotType, robotId) => | |
handleUpdatePortInfo(index, robotType, robotId) | |
} | |
onCalibrate={() => handleCalibrate(portInfo)} | |
onTeleoperate={() => onTeleoperate?.(portInfo)} | |
/> | |
))} | |
</div> | |
)} | |
</div> | |
</div> | |
</CardContent> | |
{/* Confirmation Dialog */} | |
<Dialog open={confirmDeleteDialog.open} onOpenChange={cancelDelete}> | |
<DialogContent> | |
<DialogHeader> | |
<DialogTitle>🗑️ Permanently Delete Robot Data?</DialogTitle> | |
<DialogDescription> | |
This action cannot be undone. All robot data will be permanently | |
deleted. | |
</DialogDescription> | |
</DialogHeader> | |
<div className="space-y-3"> | |
<div className="p-4 bg-red-50 rounded-lg border border-red-200"> | |
<div className="font-medium text-red-900 mb-2"> | |
Robot Information: | |
</div> | |
<div className="text-sm text-red-800 space-y-1"> | |
<div> | |
• Name:{" "} | |
<span className="font-mono"> | |
{confirmDeleteDialog.robotName} | |
</span> | |
</div> | |
<div> | |
• Serial:{" "} | |
<span className="font-mono"> | |
{confirmDeleteDialog.serialNumber} | |
</span> | |
</div> | |
</div> | |
</div> | |
<div className="p-4 bg-red-50 rounded-lg border border-red-200"> | |
<div className="font-medium text-red-900 mb-2"> | |
This will permanently delete: | |
</div> | |
<div className="text-sm text-red-800 space-y-1"> | |
<div>• Robot configuration</div> | |
<div>• Calibration data</div> | |
<div>• All saved settings</div> | |
</div> | |
</div> | |
</div> | |
<DialogFooter> | |
<Button variant="outline" onClick={cancelDelete}> | |
Cancel | |
</Button> | |
<Button variant="destructive" onClick={confirmDelete}> | |
Delete Forever | |
</Button> | |
</DialogFooter> | |
</DialogContent> | |
</Dialog> | |
</Card> | |
); | |
} | |
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<number[]>([]); | |
const [portMetadata, setPortMetadata] = useState<any>(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<Uint8Array>; | |
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 ( | |
<div className="border rounded-lg p-4 space-y-3"> | |
{/* Header with port name and status */} | |
<div className="flex items-center justify-between"> | |
<div className="flex items-center space-x-2"> | |
<div className="flex flex-col"> | |
<span className="font-medium">{portInfo.name}</span> | |
{portInfo.serialNumber && ( | |
<span className="text-xs text-gray-500 font-mono"> | |
ID:{" "} | |
{portInfo.serialNumber.length > 20 | |
? portInfo.serialNumber.substring(0, 20) + "..." | |
: portInfo.serialNumber} | |
</span> | |
)} | |
</div> | |
<Badge variant={portInfo.isConnected ? "default" : "outline"}> | |
{portInfo.isConnected ? "Connected" : "Available"} | |
</Badge> | |
</div> | |
<Button variant="destructive" size="sm" onClick={onDisconnect}> | |
Remove | |
</Button> | |
</div> | |
{/* Robot Info Display (when not editing) */} | |
{!isEditing && currentRobotType && currentRobotId && ( | |
<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">{currentRobotId}</div> | |
<div className="text-xs text-gray-600"> | |
{currentRobotType.replace("_", " ")} | |
</div> | |
</div> | |
{calibrationStatus && ( | |
<Badge variant="default" className="bg-green-100 text-green-800"> | |
✅ Calibrated | |
</Badge> | |
)} | |
</div> | |
<Button | |
variant="outline" | |
size="sm" | |
onClick={() => setIsEditing(true)} | |
> | |
Edit | |
</Button> | |
</div> | |
)} | |
{/* Setup prompt for unconfigured robots */} | |
{!isEditing && (!currentRobotType || !currentRobotId) && ( | |
<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={robotType} | |
onChange={(e) => | |
setRobotType( | |
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={robotId} | |
onChange={(e) => setRobotId(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={handleSave} disabled={!robotId.trim()}> | |
Save | |
</Button> | |
<Button size="sm" variant="outline" onClick={handleCancel}> | |
Cancel | |
</Button> | |
</div> | |
</div> | |
)} | |
{/* Calibration Status and Action */} | |
{currentRobotType && currentRobotId && ( | |
<div className="space-y-3"> | |
<div className="flex items-center justify-between"> | |
<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> | |
<div className="flex gap-2"> | |
<Button | |
size="sm" | |
variant={calibrationStatus ? "outline" : "default"} | |
onClick={onCalibrate} | |
disabled={!currentRobotType || !currentRobotId} | |
> | |
{calibrationStatus ? "Re-calibrate" : "Calibrate"} | |
</Button> | |
<Button | |
size="sm" | |
variant="outline" | |
onClick={onTeleoperate} | |
disabled={ | |
!currentRobotType || !currentRobotId || !portInfo.isConnected | |
} | |
> | |
🎮 Teleoperate | |
</Button> | |
</div> | |
</div> | |
{/* Device Info Scanner */} | |
<div className="flex items-center justify-between"> | |
<div className="text-sm text-gray-600"> | |
Scan device info and motor IDs | |
</div> | |
<Button | |
size="sm" | |
variant="outline" | |
onClick={scanDeviceInfo} | |
disabled={!portInfo.isConnected || isScanning} | |
> | |
{isScanning ? "Scanning..." : "Show Device Info"} | |
</Button> | |
</div> | |
{/* Device Info Results */} | |
{showDeviceInfo && ( | |
<div className="p-3 bg-gray-50 rounded-lg space-y-3"> | |
{/* USB Device Information */} | |
{portMetadata && ( | |
<div> | |
<div className="text-sm font-medium mb-2"> | |
📱 USB Device Info: | |
</div> | |
<div className="space-y-1 text-xs"> | |
<div className="flex justify-between"> | |
<span className="text-gray-600">Vendor ID:</span> | |
<span className="font-mono">{portMetadata.vendorId}</span> | |
</div> | |
<div className="flex justify-between"> | |
<span className="text-gray-600">Product ID:</span> | |
<span className="font-mono"> | |
{portMetadata.productId} | |
</span> | |
</div> | |
<div className="flex justify-between"> | |
<span className="text-gray-600">Serial Number:</span> | |
<span className="font-mono text-green-600 font-semibold"> | |
{portMetadata.serialNumber} | |
</span> | |
</div> | |
<div className="flex justify-between"> | |
<span className="text-gray-600">Manufacturer:</span> | |
<span>{portMetadata.manufacturerName}</span> | |
</div> | |
<div className="flex justify-between"> | |
<span className="text-gray-600">Product:</span> | |
<span>{portMetadata.productName}</span> | |
</div> | |
{portMetadata.usbVersionMajor && ( | |
<div className="flex justify-between"> | |
<span className="text-gray-600">USB Version:</span> | |
<span> | |
{portMetadata.usbVersionMajor}. | |
{portMetadata.usbVersionMinor} | |
</span> | |
</div> | |
)} | |
{portMetadata.deviceClass !== undefined && ( | |
<div className="flex justify-between"> | |
<span className="text-gray-600">Device Class:</span> | |
<span> | |
0x | |
{portMetadata.deviceClass | |
.toString(16) | |
.padStart(2, "0")} | |
</span> | |
</div> | |
)} | |
</div> | |
</div> | |
)} | |
{/* Motor IDs */} | |
<div> | |
<div className="text-sm font-medium mb-2"> | |
🤖 Found Motor IDs: | |
</div> | |
{motorIDs.length > 0 ? ( | |
<div className="flex flex-wrap gap-2"> | |
{motorIDs.map((id) => ( | |
<Badge key={id} variant="outline" className="text-xs"> | |
Motor {id} | |
</Badge> | |
))} | |
</div> | |
) : ( | |
<div className="text-sm text-gray-500"> | |
No motor IDs found. Check connection and power. | |
</div> | |
)} | |
</div> | |
<Button | |
size="sm" | |
variant="outline" | |
onClick={() => setShowDeviceInfo(false)} | |
className="mt-2 text-xs" | |
> | |
Hide | |
</Button> | |
</div> | |
)} | |
</div> | |
)} | |
</div> | |
); | |
} | |